whassan commited on
Commit
6b3874e
·
verified ·
1 Parent(s): 64fb236

Deploy Sensorflow Studio

Browse files
Files changed (49) hide show
  1. .gitattributes +6 -0
  2. app_backend.py +5 -0
  3. docs/retro/compatibility-matrix.md +80 -0
  4. docs/retro/final-report.md +205 -0
  5. docs/retro/screenshots/01-hardware-compat-card.png +3 -0
  6. docs/retro/screenshots/02-evidence-chain-observed.png +3 -0
  7. docs/retro/screenshots/03-evidence-chain-policy-gate.png +3 -0
  8. docs/retro/screenshots/04-scorecard.png +3 -0
  9. docs/retro/screenshots/05-retrieved-standards.png +3 -0
  10. docs/retro/screenshots/06-audit-trail.png +3 -0
  11. sensorflow/retro/README.md +94 -0
  12. sensorflow/retro/__init__.py +24 -0
  13. sensorflow/retro/agent/__init__.py +11 -0
  14. sensorflow/retro/agent/orchestrator.py +642 -0
  15. sensorflow/retro/agent/prompts.py +48 -0
  16. sensorflow/retro/agent/scripted.py +133 -0
  17. sensorflow/retro/api.py +135 -0
  18. sensorflow/retro/fixtures/benign_fp_static_sign.json +85 -0
  19. sensorflow/retro/fixtures/missed_pedestrian_rain.json +99 -0
  20. sensorflow/retro/fixtures/missing_telemetry_variant.json +70 -0
  21. sensorflow/retro/fixtures/noncritical_fn_parked_vehicle.json +73 -0
  22. sensorflow/retro/fixtures/phantom_brake_plastic_bag.json +97 -0
  23. sensorflow/retro/inference/__init__.py +2 -0
  24. sensorflow/retro/inference/client.py +272 -0
  25. sensorflow/retro/inference/compat.py +241 -0
  26. sensorflow/retro/inference/env_detect.py +228 -0
  27. sensorflow/retro/inference/vllm_server/benchmark.py +154 -0
  28. sensorflow/retro/inference/vllm_server/model.env +6 -0
  29. sensorflow/retro/inference/vllm_server/runtime.env +4 -0
  30. sensorflow/retro/inference/vllm_server/server.env +5 -0
  31. sensorflow/retro/inference/vllm_server/start_vllm.sh +47 -0
  32. sensorflow/retro/metrics.py +239 -0
  33. sensorflow/retro/policy.py +288 -0
  34. sensorflow/retro/rag/__init__.py +8 -0
  35. sensorflow/retro/rag/chunking.py +74 -0
  36. sensorflow/retro/rag/corpus.py +281 -0
  37. sensorflow/retro/rag/embedder.py +64 -0
  38. sensorflow/retro/rag/evalharness.py +83 -0
  39. sensorflow/retro/rag/retriever.py +81 -0
  40. sensorflow/retro/rag/store.py +112 -0
  41. sensorflow/retro/requirements-retro.txt +27 -0
  42. sensorflow/retro/scorecard.py +240 -0
  43. sensorflow/retro/store.py +86 -0
  44. sensorflow/retro/tools/__init__.py +1 -0
  45. sensorflow/retro/tools/builtin.py +308 -0
  46. sensorflow/retro/tools/mcp_server.py +70 -0
  47. sensorflow/retro/tools/registry.py +184 -0
  48. src/App.tsx +5 -0
  49. src/components/help/HelpMenu.tsx +2 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/retro/screenshots/01-hardware-compat-card.png filter=lfs diff=lfs merge=lfs -text
37
+ docs/retro/screenshots/02-evidence-chain-observed.png filter=lfs diff=lfs merge=lfs -text
38
+ docs/retro/screenshots/03-evidence-chain-policy-gate.png filter=lfs diff=lfs merge=lfs -text
39
+ docs/retro/screenshots/04-scorecard.png filter=lfs diff=lfs merge=lfs -text
40
+ docs/retro/screenshots/05-retrieved-standards.png filter=lfs diff=lfs merge=lfs -text
41
+ docs/retro/screenshots/06-audit-trail.png filter=lfs diff=lfs merge=lfs -text
app_backend.py CHANGED
@@ -1488,6 +1488,11 @@ app.include_router(rca_router)
1488
  from sensorflow.hillclimb.api import router as hillclimb_router
1489
  app.include_router(hillclimb_router)
1490
 
 
 
 
 
 
1491
  # Multimodal rare-event mining & perception QA (costumed-pedestrian miner).
1492
  from sensorflow.raremine.api import router as raremine_router
1493
  app.include_router(raremine_router)
 
1488
  from sensorflow.hillclimb.api import router as hillclimb_router
1489
  app.include_router(hillclimb_router)
1490
 
1491
+ # Agentic Retrospective Safety Analyzer (evidence-tiered failure retrospectives,
1492
+ # safety-case RAG, deterministic severity + launch policy gate).
1493
+ from sensorflow.retro.api import router as retro_router
1494
+ app.include_router(retro_router)
1495
+
1496
  # Multimodal rare-event mining & perception QA (costumed-pedestrian miner).
1497
  from sensorflow.raremine.api import router as raremine_router
1498
  app.include_router(raremine_router)
docs/retro/compatibility-matrix.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CUDA vs ROCm compatibility matrix for the retro inference layer
2
+
3
+ Scope: running the vLLM server (`sensorflow/retro/inference/vllm_server/`)
4
+ that backs the Retrospective Analyzer's `vllm` backend.
5
+
6
+ **Honesty statement.** This matrix was written on a macOS (Apple Silicon)
7
+ machine where **none of the GPU rows below can be executed or verified**. It
8
+ is compiled from general knowledge of the CUDA/ROCm/vLLM ecosystems as of the
9
+ author's knowledge cutoff. vLLM moves fast: **verify every row against the
10
+ release notes of the vLLM version you actually install.** Rows marked
11
+ UNCERTAIN are flagged rather than guessed.
12
+
13
+ ## Platform support at a glance
14
+
15
+ | Platform | vLLM | Notes |
16
+ |---|---|---|
17
+ | Linux + NVIDIA (CUDA 12.x) | Supported | Primary, best-tested target |
18
+ | Linux + AMD (ROCm 6.x, MI200/MI300 class) | Supported | Official ROCm builds; narrower feature set |
19
+ | Linux + AMD consumer (RDNA) | UNCERTAIN | Community/experimental; verify per release |
20
+ | macOS (Apple Silicon or Intel) | **NOT SUPPORTED** | No CUDA, no ROCm; this dev machine. Use Ollama (Metal) locally |
21
+ | Windows native | NOT SUPPORTED | WSL2 + CUDA is the workaround |
22
+ | CPU-only Linux | Limited/experimental | A CPU backend exists but is not a serving-grade path; verify |
23
+
24
+ ## Package installation
25
+
26
+ | Aspect | CUDA (NVIDIA) | ROCm (AMD) |
27
+ |---|---|---|
28
+ | Install | `pip install vllm` (prebuilt wheels, CUDA 12.x) | No universal PyPI wheel historically; use AMD's ROCm docker images or build from source with ROCm 6.x. Verify current release — ROCm wheels have been appearing |
29
+ | Driver floor | Driver >= 535-series for CUDA 12.x wheels (verify) | ROCm >= 6.0 for current vLLM; MI300 needs recent ROCm |
30
+ | Attention kernels | FlashAttention-2 / FlashInfer prebuilt | ROCm FlashAttention port (Composable Kernel / Triton based); fewer head-dim/feature combos supported |
31
+
32
+ ## PyTorch builds
33
+
34
+ | Aspect | CUDA | ROCm |
35
+ |---|---|---|
36
+ | Wheel source | `pip install torch` (cu12x wheels on PyPI) | `pip install torch --index-url https://download.pytorch.org/whl/rocm6.x` |
37
+ | Detection | `torch.version.cuda` set, `torch.cuda.is_available()` | `torch.version.hip` set; ROCm *masquerades as* `torch.cuda` (HIP) — `torch.cuda.is_available()` is True on ROCm builds |
38
+ | Pitfall | CPU-only wheel silently installed if index not pinned | Mixing a CUDA wheel onto a ROCm host fails at runtime, not install time |
39
+
40
+ ## Quantization support (verify against your vLLM release)
41
+
42
+ | Method | CUDA | ROCm |
43
+ |---|---|---|
44
+ | AWQ | Supported (Ampere+; kernels tuned for 4-bit) | UNCERTAIN/limited — AWQ kernels historically CUDA-only; do not assume |
45
+ | GPTQ | Supported (incl. Marlin kernels on newer GPUs) | Partial — basic GPTQ paths have worked via Triton; Marlin is CUDA-specific |
46
+ | FP8 (W8A8) | Supported on Hopper/Ada (H100, L40S); emulated/absent on older | Supported on MI300 (native FP8); not on older CDNA |
47
+ | INT8 (SmoothQuant-style) | Supported | UNCERTAIN — verify |
48
+ | bitsandbytes | Supported (load-time) | NOT SUPPORTED historically |
49
+
50
+ `compat.py` encodes a conservative subset of this table
51
+ (`CUDA_QUANTIZATIONS` / `ROCM_QUANTIZATIONS`) and says "verify against the
52
+ installed vLLM release" in its PASS messages.
53
+
54
+ ## Memory management
55
+
56
+ | Aspect | CUDA | ROCm |
57
+ |---|---|---|
58
+ | PagedAttention KV cache | Core feature, mature | Same architecture, HIP-compiled kernels |
59
+ | `GPU_MEMORY_UTILIZATION` | Fraction of VRAM pre-allocated (default 0.9) | Same flag; MI300's 192 GB HBM changes sizing math |
60
+ | Unified/managed memory | Not used by vLLM | Not used by vLLM |
61
+ | Multi-GPU (TENSOR_PARALLEL_SIZE) | NCCL | RCCL (NCCL API-compatible); verify topology support (xGMI vs NVLink) |
62
+
63
+ ## Kernel compatibility
64
+
65
+ | Aspect | CUDA | ROCm |
66
+ |---|---|---|
67
+ | Core kernels | CUDA C++/CUTLASS + Triton | HIPified C++ + Composable Kernel + Triton-on-ROCm |
68
+ | Triton | Mature | Works on ROCm but kernel autotuning/coverage lags; some fused ops fall back to slower paths |
69
+ | CUDA Graphs | Used for decode speedups | HIP graph support has lagged; verify (`--enforce-eager` is the fallback) |
70
+ | Custom all-reduce | CUDA-specific fast path | Typically disabled on ROCm; RCCL path used |
71
+
72
+ ## What this repo can and cannot claim
73
+
74
+ - CAN claim (verified here): vLLM is unsupported on this macOS machine —
75
+ `env_detect.py` + `compat.py` prove it at runtime, and the test suite
76
+ asserts it.
77
+ - CANNOT claim: any vLLM latency/throughput number. `benchmark.py` produces
78
+ them only when pointed at a real CUDA/ROCm host; no such run happened here.
79
+ - Local real-inference numbers, if any appear in reports, come from an
80
+ **Ollama server on Apple CPU/Metal** and are labeled as such.
docs/retro/final-report.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agentic Retrospective Safety Analyzer — Final Report
2
+
3
+ Delivered in one run, structured phase-by-phase per the source spec. All numbers
4
+ below are real measurements taken on this machine; anything not measurable here
5
+ is explicitly labeled as such.
6
+
7
+ **Verification totals: 74/74 retro tests green; full repo suite 598/598 green;
8
+ tsc clean; production build green; browser walkthrough completed (screenshots
9
+ in `docs/retro/screenshots/`).**
10
+
11
+ ---
12
+
13
+ ## Hardware findings on THIS machine (real, measured)
14
+
15
+ | Property | Value |
16
+ |---|---|
17
+ | OS | macOS 26.6.1 (Darwin), arm64 — Apple Silicon |
18
+ | GPU | Apple M4 (unified memory, Metal/MPS only) |
19
+ | Python | 3.11.9 (`.venv`) |
20
+ | CUDA / ROCm | **None — vLLM UNSUPPORTED on this machine** |
21
+ | Ollama | **Not reachable** at `localhost:11434` at verification time (connection refused) |
22
+ | Compat chain | `gpu` = FAIL (clear message); `driver/torch/vllm/model/quantization` = SKIPPED (blocked by failed `gpu` link) |
23
+
24
+ Measured latencies (labeled honestly):
25
+
26
+ - **Mock backend, end-to-end analysis via HTTP API**: 0.26–0.30 s per analysis
27
+ (3 runs: 0.298 s, 0.261 s, 0.275 s) on the warm server. This is deterministic
28
+ scripted analysis — no LLM.
29
+ - **Ollama**: no numbers reported because no server was running. The client
30
+ supports real inference + latency measurement and labels results
31
+ "Ollama-on-CPU/Metal" when a server is present.
32
+ - **vLLM**: NO benchmark numbers exist anywhere in this delivery. The benchmark
33
+ script (`sensorflow/retro/inference/vllm_server/benchmark.py`) is runnable
34
+ only on CUDA/ROCm hosts and says so; it was not executed here.
35
+
36
+ ## Dependency outcomes
37
+
38
+ | Dependency | Outcome |
39
+ |---|---|
40
+ | `chromadb` | **Installed cleanly, used live.** The RAG index reports `store_backend: chromadb` at runtime. Deterministic hashed-TF + numpy cosine store remains as the always-available fallback (same interface), and tests force the fallback path so they never depend on chroma. |
41
+ | `mcp` | **Installed cleanly.** Bonus MCP server wrapper (`sensorflow/retro/tools/mcp_server.py`) adapts the tool registry; the installed package exposes `MCPServer` (not `FastMCP`), handled dynamically. The registry itself remains the MCP-style boundary. |
42
+ | `pydantic-ai-slim` | **Installed**, but orchestration deliberately uses the internal typed loop (typed pydantic contracts + explicit tool loop) for full determinism and auditability; PydanticAI adds no value on the mock path and would obscure the audit trail. |
43
+
44
+ ## Reuse map (read-only imports)
45
+
46
+ - `sensorflow/safety/ssam_ext.py` → `projected_ttc` math for TTC (with validity flags added on top).
47
+ - `sensorflow/megaeval` stats → `DistributionAnalysisTool` delegates distribution-shift summaries.
48
+ - `sensorflow/seqeval/paired.py` → `PairedSequentialTest` for statistical significance in the scorecard.
49
+ - `sensorflow/evaluation/copilot.py` pattern → Ollama backend client shape (endpoint, model discovery, timeouts).
50
+
51
+ ## Test counts
52
+
53
+ 74 retro tests (all passing), plus full-repo suite 598/598:
54
+
55
+ | File | Tests | Covers |
56
+ |---|---|---|
57
+ | `test_inference.py` | 8 | env detection on this machine, vLLM-unsupported honesty, compat chain clarity, mock client contract |
58
+ | `test_rag.py` | 9 | chunking, retrieval eval precision, synthetic labels always present, no-citation-without-retrieval |
59
+ | `test_tools.py` | 8 | read-only default, write authorization, audit completeness, path allowlist, schemas |
60
+ | `test_metrics.py` | 14 | stopping-distance parameterization, TTC validity flags, SCR, behavioral impact |
61
+ | `test_policy.py` | 13 | asymmetric costs (benign-FN < phantom-FP), gate determinism, AI-severity override flow, INSUFFICIENT_EVIDENCE never PASS |
62
+ | `test_agent.py` | 10 | both canonical fixtures, fact/inference separation, stripped telemetry → UNKNOWN, persistence |
63
+ | `test_api.py` | 9 | full API lifecycle via TestClient, vLLM local-unavailability |
64
+ | `test_optional_deps.py` | 3 | chromadb store, MCP server bonus (skip if not installed) |
65
+
66
+ ---
67
+
68
+ ## Phase 1 — Inference foundation (`sensorflow/retro/inference/`)
69
+
70
+ **What was built**: `env_detect.py` (real OS/Python/Torch/GPU/CUDA/ROCm/MPS
71
+ detection; detected Apple M4 on this machine), `compat.py` (validation chain
72
+ GPU→driver→torch→vLLM→model→quantization; failed links block downstream checks
73
+ with clear reasons), `client.py` (pluggable mock/ollama/vllm client with health
74
+ check, deterministic test-prompt validation, token counts, latency),
75
+ `vllm_server/` (config split into `server.env` / `model.env` / `runtime.env`,
76
+ `start_vllm.sh` that refuses to run on macOS, `benchmark.py` with TTFT
77
+ P50/P95/P99, tokens/sec, warm-vs-cold), `requirements-retro.txt`, package README.
78
+
79
+ **How to run**:
80
+
81
+ ```bash
82
+ .venv/bin/python -m sensorflow.retro.inference.env_detect # env report
83
+ .venv/bin/python -m sensorflow.retro.inference.compat # compat chain
84
+ # vLLM host (CUDA/ROCm only): edit *.env, then ./start_vllm.sh; benchmark.py
85
+ ```
86
+
87
+ **Validation checklist**: env detection real on this machine PASS · vLLM
88
+ honestly reported unsupported PASS · mock client contract PASS · vLLM
89
+ configs/scripts provided-but-not-executed, stated explicitly PASS.
90
+
91
+ **Known limitations**: vLLM artifacts unverified here by definition; Ollama
92
+ path exercised only via health-check code (no server running); compatibility
93
+ matrix is general-knowledge with "verify against current vLLM release" caveats.
94
+
95
+ ## Phase 2 — Safety-case RAG (`sensorflow/retro/rag/`)
96
+
97
+ **What was built**: parser + configurable chunking, embedder (chromadb default
98
+ embedder when installed; deterministic hashed-TF fallback with documented
99
+ tradeoff), store (`ChromaStore` / `NumpyCosineStore` behind one protocol),
100
+ retriever that ALWAYS returns `{source, document, version, section,
101
+ retrieved_text, relevance_score}` + full metadata, seed corpus of 7 SYNTHETIC
102
+ documents (safety requirements, launch criteria, perception requirements, ODD,
103
+ historical retrospectives, evaluation policy, SOTIF concept paraphrase — every
104
+ one tagged `SYNTHETIC_EXAMPLE / NOT_A_REAL_STANDARD` in content AND metadata),
105
+ retrieval eval harness.
106
+
107
+ **Real numbers**: precision@4 = **1.0** over 14 expected-source queries,
108
+ `store_backend: chromadb` (live server) — fallback path separately tested.
109
+
110
+ **Hard rule (tested)**: no safety-requirement citation is ever emitted without
111
+ a retrieval hit backing it; every scorecard `retrieved_standards` entry maps to
112
+ an audited `safety_standard_rag` call.
113
+
114
+ **How to run**: `GET /api/retro/rag/search?q=...&k=4`, `GET /api/retro/rag/eval`.
115
+
116
+ **Known limitations**: corpus is small and synthetic by design; lexical
117
+ fallback scores are not comparable to chroma's embedding distances (documented).
118
+
119
+ ## Phase 3 — Agent + MCP-style tools (`sensorflow/retro/agent/`, `tools/`)
120
+
121
+ **What was built**: `ToolRegistry` (input/output schema, `read_only` default
122
+ true, timeout, error behavior, audit log with args/result-hash/timestamp for
123
+ every call including denials); tools: `LogReaderTool` (allowlisted to `runs/`
124
+ + fixtures dir), `SafetyStandardRAGTool`, `MetricCalculatorTool` (stopping
125
+ distance with reaction/velocity/grade/friction/decel/system latency reusing
126
+ ssam_ext math; TTC with validity flags; SCR vs policy criticality; behavioral
127
+ impact observed-vs-corrected), `HistoricalFailureSearchTool`,
128
+ `DistributionAnalysisTool` (megaeval delegation), `CreateEvaluationCaseTool`
129
+ (the ONE write tool, requires explicit authorization, audited). Bonus: real MCP
130
+ server wrapper since `mcp` installed. Orchestrator: internal typed loop
131
+ (Log Agent → Evidence Analyzer → RAG Agent → Safety Synthesizer); mock backend
132
+ = deterministic scripted analysis, Ollama backend = real LLM synthesis with
133
+ follow-up RAG queries. Five fixtures including both canonical examples and the
134
+ missing-telemetry / benign-FP / non-critical-FN variants.
135
+
136
+ **Tested guarantees**: stripped telemetry → UNKNOWN, never guessed; fact and
137
+ inference never mixed (hypotheses are Tier 4 only); write tool denied without
138
+ authorization; every tool call audited.
139
+
140
+ **How to run**: `POST /api/retro/analyze?fixture_id=missed_pedestrian_rain&backend=mock`
141
+ (or upload a JSON log; `backend=ollama` when a server is running).
142
+
143
+ **Known limitations**: Ollama synthesis path implemented and unit-tested at the
144
+ prompt/parsing level but not exercised end-to-end here (no server); MCP server
145
+ wrapper is smoke-tested, not exercised by a real MCP client.
146
+
147
+ ## Phase 4 — Scorecard + policy gate (`scorecard.py`, `policy.py`)
148
+
149
+ **What was built**: evidence hierarchy enforced in types (Tier1 OBSERVED /
150
+ Tier2 DERIVED / Tier3 RETRIEVED / Tier4 AI_HYPOTHESIS); strictly-typed
151
+ `RetrospectiveScorecard` with every spec field + markdown renderer showing the
152
+ FACT / DERIVED FACT / RETRIEVED REQUIREMENT / AI HYPOTHESIS / SAFETY
153
+ DETERMINATION distinction; deterministic severity BENIGN/DISRUPTIVE/CRITICAL/
154
+ FATAL from contextual evidence with the asymmetric FN/FP cost model (VRU ×
155
+ distance-vs-stopping × relative motion × remaining reaction time for FN;
156
+ intervention magnitude × disruption for FP) — tested that a benign distant FN
157
+ ranks BELOW a hard-braking phantom FP; LLM severity proposal adjudicated by the
158
+ policy engine with divergence recorded + flagged for human review; policy-
159
+ versioned launch gate (`retro-policy/1.0.0`) where INSUFFICIENT_EVIDENCE can
160
+ never become PASS.
161
+
162
+ **Fixture B verified result** (mock, end-to-end): FALSE_NEGATIVE, severity
163
+ CRITICAL (AI proposal agreed, no divergence), launch FAIL (GATE-01), human
164
+ review required, 38 evidence items, 7 retrieved standards, 2 hypotheses,
165
+ stopping distance 26.18 m vs 26.0 m range, TTC 1.7 s with validity flags.
166
+
167
+ **Known limitations**: severity weights are demonstration policy values
168
+ (versioned, documented), not calibrated to a real fleet's risk model.
169
+
170
+ ## API + persistence
171
+
172
+ All endpoints live under `/api/retro` (env, compat, backends, rag/search,
173
+ rag/eval, fixtures, analyze, analyses, analyses/{id}, analyses/{id}/audit,
174
+ tools) — all verified 200 via curl sweep and the pytest TestClient suite.
175
+ Artifacts persist under `runs/retro/{analyses,audit,uploads}`.
176
+
177
+ ## Frontend
178
+
179
+ "Retrospective Analyzer" page under SAFETY & COMPLIANCE. All new files
180
+ (`src/pages/retro/`, `src/components/retro/`, `src/services/retro.ts`,
181
+ `src/types/retro.ts`) + minimal additive insertions (App.tsx nav/import/render,
182
+ LabelEvalContext page id, HelpMenu page name, pageHelp + glossary entries —
183
+ required because those maps are typed `Record<PageId, …>`). Delivers: fixture
184
+ picker + backend selector with honest availability badges, vertical evidence-
185
+ chain view color-coded by tier with legend, scorecard with severity/launch
186
+ banner + human-review flag, retrieved-standards panel with SYNTHETIC_EXAMPLE
187
+ badges, audit-trail tab, hardware/compat card. `tsc` clean; `vite build` green.
188
+
189
+ ## Verification & servers
190
+
191
+ - Full pytest: **598 passed, 0 failed** (includes 74 retro tests).
192
+ - Browser walkthrough of fixture B end-to-end on `http://localhost:5199/#/retro`
193
+ (screenshots 01–06 in `docs/retro/screenshots/`).
194
+ - The pre-existing servers on :8000 and :5173 were left untouched; because the
195
+ :8000 backend predates this router (started without `--reload`), verification
196
+ runs on a fresh backend at :8100 paired with `vite.retro-verify.config.ts` on
197
+ :5199 — both left running. Nothing was committed.
198
+
199
+ ## Honest gaps
200
+
201
+ 1. No vLLM execution or benchmark numbers — impossible on macOS; scripts ship unverified-by-design.
202
+ 2. No real-LLM end-to-end run — Ollama was not running at verification time; the mock path is the verified path.
203
+ 3. The safety-case corpus is synthetic; SOTIF content is concept paraphrase only.
204
+ 4. The long-running :8000/:5173 servers won't show the retro API/page until restarted (concurrent-work constraint, not a defect).
205
+ 5. PydanticAI installed but intentionally unused for orchestration (typed internal loop chosen for auditability).
docs/retro/screenshots/01-hardware-compat-card.png ADDED

Git LFS Details

  • SHA256: 5381b34083819052712bca31b9644850bf5553147b2346b120026a10e1ecccdd
  • Pointer size: 131 Bytes
  • Size of remote file: 502 kB
docs/retro/screenshots/02-evidence-chain-observed.png ADDED

Git LFS Details

  • SHA256: 8abc902baa71e74552ab46734a35ea3b467d591f42859ae99ba5dfdb7fed2f62
  • Pointer size: 131 Bytes
  • Size of remote file: 591 kB
docs/retro/screenshots/03-evidence-chain-policy-gate.png ADDED

Git LFS Details

  • SHA256: f24300df5f675507ab4b7649b53db839d235cd6be2b1c954680d0ffac3abe64a
  • Pointer size: 131 Bytes
  • Size of remote file: 578 kB
docs/retro/screenshots/04-scorecard.png ADDED

Git LFS Details

  • SHA256: 5033f2518ad74b2ac1978e91089a7226fda38ce1cf2062e8aac79e76bf0146dc
  • Pointer size: 131 Bytes
  • Size of remote file: 672 kB
docs/retro/screenshots/05-retrieved-standards.png ADDED

Git LFS Details

  • SHA256: 40876f962a0a4c93408ceaa7f79bd5a69b84510882394b27ae9267599b9a6627
  • Pointer size: 131 Bytes
  • Size of remote file: 878 kB
docs/retro/screenshots/06-audit-trail.png ADDED

Git LFS Details

  • SHA256: 692e8f8c0d7b002fba1985e9bfb9744d67db92642c6125bb444b88317b1d6ec1
  • Pointer size: 131 Bytes
  • Size of remote file: 730 kB
sensorflow/retro/README.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Retro — Agentic Retrospective Safety Analyzer
2
+
3
+ Local-first, evidence-driven analysis of perception failures. Traceable chain:
4
+
5
+ ```
6
+ RAW FAILURE -> OBSERVED EVIDENCE -> DERIVED METRICS -> RETRIEVED ENGINEERING
7
+ EVIDENCE -> AGENT HYPOTHESIS -> BEHAVIORAL ANALYSIS -> SAFETY POLICY ->
8
+ RETROSPECTIVE SCORECARD -> HUMAN DECISION
9
+ ```
10
+
11
+ Core rule: the LLM interprets/correlates/hypothesizes; deterministic code
12
+ computes metrics and owns the safety boundary. Launch determinations only
13
+ pass through the deterministic policy gate (`policy.py`).
14
+
15
+ ## Backends and reproducible startup
16
+
17
+ ### mock (default — no model, fully deterministic)
18
+ Nothing to start. All tests run against this backend.
19
+
20
+ ```bash
21
+ .venv/bin/python -c "
22
+ from sensorflow.retro.inference.client import get_backend
23
+ b = get_backend('mock'); print(b.health()); print(b.validate())"
24
+ ```
25
+
26
+ ### ollama (real local inference on this macOS machine — CPU/Metal)
27
+ ```bash
28
+ # 1. Install & start Ollama (https://ollama.com), then pull a model:
29
+ ollama pull gemma3:latest # or any chat model
30
+ # 2. Optionally pin the model:
31
+ export RETRO_OLLAMA_MODEL=gemma3:latest
32
+ # 3. Validate:
33
+ .venv/bin/python -c "
34
+ from sensorflow.retro.inference.client import get_backend
35
+ b = get_backend('ollama'); print(b.health()); print(b.validate())"
36
+ ```
37
+ Numbers from this backend are **Ollama-on-CPU/Metal**, never vLLM numbers.
38
+
39
+ ### vllm (CUDA/ROCm Linux hosts ONLY — not runnable on this machine)
40
+ ```bash
41
+ # On the GPU host:
42
+ pip install vllm
43
+ cd sensorflow/retro/inference/vllm_server
44
+ MODEL_NAME=meta-llama/Llama-3.1-8B-Instruct PORT=8001 ./start_vllm.sh
45
+ # Benchmark (TTFT p50/p95/p99, tokens/sec, warm-vs-cold):
46
+ python benchmark.py --base-url http://localhost:8001/v1 --label "vLLM <GPU model>"
47
+
48
+ # Back on this machine, point the client at the host:
49
+ export RETRO_VLLM_URL=http://gpu-host:8001/v1
50
+ ```
51
+ `start_vllm.sh` refuses to run on macOS with a clear error. `env_detect.py`
52
+ and `compat.py` report this machine as unsupported for vLLM (verified by
53
+ tests). See `docs/retro/compatibility-matrix.md` for CUDA vs ROCm.
54
+
55
+ ## Quick tour
56
+
57
+ ```bash
58
+ # Environment + compat report
59
+ .venv/bin/python -m sensorflow.retro.inference.compat
60
+
61
+ # Analyze a canonical fixture end to end (mock backend)
62
+ .venv/bin/python -c "
63
+ from sensorflow.retro.agent.orchestrator import analyze_fixture
64
+ sc = analyze_fixture('missed_pedestrian_rain', backend='mock')
65
+ print(sc.render_markdown())"
66
+
67
+ # API (mounted on the studio backend at :8000)
68
+ curl -s localhost:8000/api/retro/env | python -m json.tool
69
+ curl -s -X POST 'localhost:8000/api/retro/analyze?fixture_id=phantom_brake_plastic_bag&backend=mock'
70
+ ```
71
+
72
+ ## Layout
73
+
74
+ - `inference/` — env detection, vLLM compat chain, pluggable client,
75
+ vLLM server configs + startup + benchmark (GPU hosts only).
76
+ - `rag/` — safety-case retrieval. Seed corpus is **SYNTHETIC** demonstration
77
+ material; every synthetic rule is tagged `SYNTHETIC_EXAMPLE` /
78
+ `NOT_A_REAL_STANDARD` in content and metadata. SOTIF/ISO 21448 entries are
79
+ paraphrase-level concept summaries, not standard text.
80
+ - `tools/` — MCP-style audited tool registry (read-only default, path
81
+ allowlists, audit trail with args/result-hash/timestamp).
82
+ - `agent/` — orchestrator: Log Agent -> Evidence Analyzer -> RAG Agent ->
83
+ Safety Synthesizer. Mock backend = deterministic scripted analysis.
84
+ - `scorecard.py` — tiered evidence (OBSERVED/DERIVED/RETRIEVED/AI_HYPOTHESIS)
85
+ and the strictly-typed `RetrospectiveScorecard`.
86
+ - `policy.py` — deterministic severity framework (asymmetric FN/FP cost) and
87
+ the versioned launch-recommendation gate.
88
+ - `api.py` — FastAPI router at `/api/retro`; artifacts under `runs/retro/`.
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ .venv/bin/python -m pytest tests/test_retro -q
94
+ ```
sensorflow/retro/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agentic Retrospective Safety Analyzer (retro).
2
+
3
+ Evidence-driven analysis of perception failures with a traceable chain:
4
+
5
+ RAW FAILURE -> OBSERVED EVIDENCE -> DERIVED METRICS -> RETRIEVED
6
+ ENGINEERING EVIDENCE -> AGENT HYPOTHESIS -> BEHAVIORAL ANALYSIS ->
7
+ SAFETY POLICY -> RETROSPECTIVE SCORECARD -> HUMAN DECISION
8
+
9
+ Core rule: the LLM interprets, correlates, and hypothesizes; deterministic
10
+ code computes every metric and owns the safety boundary. Launch
11
+ determinations only ever pass through the deterministic policy gate
12
+ (sensorflow.retro.policy); INSUFFICIENT_EVIDENCE never becomes PASS.
13
+
14
+ Package layout:
15
+ inference/ pluggable LLM backends (vllm | ollama | mock) + env/compat
16
+ rag/ safety-case retrieval (synthetic, clearly-labeled corpus)
17
+ tools/ MCP-style audited tool registry
18
+ agent/ retrospective orchestrator (Log -> Evidence -> RAG -> Synth)
19
+ scorecard.py tier-tagged evidence + RetrospectiveScorecard model
20
+ policy.py deterministic severity framework + launch gate
21
+ api.py FastAPI router mounted at /api/retro
22
+ """
23
+
24
+ AGENT_VERSION = "retro-agent/1.0.0"
sensorflow/retro/agent/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrospective agent: Log Agent -> Evidence Analyzer -> RAG Agent ->
2
+ Safety Synthesizer, with ReAct-style audited tool use.
3
+
4
+ Orchestration note: pydantic-ai installed cleanly in this environment, but
5
+ the orchestrator deliberately uses the internal typed loop (typed pydantic
6
+ stage contracts + the audited ToolRegistry) as the execution engine for BOTH
7
+ backends, because the deterministic mock path and the per-call audit
8
+ requirements must hold with no model present. The LLM (Ollama, when
9
+ reachable) contributes hypotheses, severity proposals, and follow-up
10
+ retrieval queries — never metrics or determinations.
11
+ """
sensorflow/retro/agent/orchestrator.py ADDED
@@ -0,0 +1,642 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrospective orchestrator: the typed four-stage agent pipeline.
2
+
3
+ Log Agent reads the artifact through the audited registry,
4
+ derives the failure type, extracts TIER1 facts,
5
+ and explicitly lists missing fields (UNKNOWN).
6
+ Evidence Analyzer drives the deterministic MetricCalculatorTool
7
+ (stopping distance, TTC, SCR impact, behavioral
8
+ impact) -> TIER2 derived facts.
9
+ RAG Agent builds failure-conditioned queries, retrieves
10
+ safety-case requirements (TIER3), historical
11
+ matches, and distribution findings.
12
+ Safety Synthesizer LLM (Ollama) or deterministic scripted analysis
13
+ (mock) -> TIER4 hypotheses + severity PROPOSAL.
14
+
15
+ The deterministic policy engine then validates/overrides the proposal and
16
+ produces the launch determination; the assembled RetrospectiveScorecard is
17
+ persisted with its full audit trail.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from typing import Any, Dict, List, Optional, Tuple
25
+
26
+ from pydantic import BaseModel, Field
27
+
28
+ from sensorflow.retro import AGENT_VERSION, store
29
+ from sensorflow.retro.inference.client import InferenceBackend, get_backend
30
+ from sensorflow.retro.agent.prompts import SYSTEM_PROMPT, synthesis_prompt
31
+ from sensorflow.retro.agent.scripted import (scripted_hypotheses,
32
+ scripted_severity_proposal)
33
+ from sensorflow.retro.policy import (POLICY_VERSION, SeverityContext,
34
+ adjudicate_severity, compute_severity,
35
+ launch_gate)
36
+ from sensorflow.retro.scorecard import (EvidenceItem, EvidenceTier,
37
+ RetrievedStandard,
38
+ RetrospectiveScorecard,
39
+ RootCauseHypothesis, Severity,
40
+ StatSignificance, UNKNOWN,
41
+ UncertaintyReport)
42
+ from sensorflow.retro.tools.builtin import build_registry
43
+ from sensorflow.retro.tools.registry import ToolRegistry
44
+
45
+ NON_HAZARD_CLASSES = {"plastic_bag", "debris", "cardboard", "balloon",
46
+ "traffic_sign", "vegetation", "steam", "shadow"}
47
+ DEFAULT_REACTION_TIME_S = 0.25
48
+
49
+
50
+ # --------------------------------------------------------- stage contracts
51
+
52
+ class LogExtract(BaseModel):
53
+ log: Dict[str, Any]
54
+ source_path: str
55
+ present_fields: List[str]
56
+ missing_fields: List[str]
57
+ failure_type: str
58
+ failure_type_rationale: str
59
+ observed_evidence: List[EvidenceItem]
60
+
61
+
62
+ class EvidenceAnalysis(BaseModel):
63
+ stopping: Dict[str, Any]
64
+ ttc: Dict[str, Any]
65
+ scr: Dict[str, Any]
66
+ behavioral: Dict[str, Any]
67
+ derived_evidence: List[EvidenceItem]
68
+ unknown_metrics: List[str]
69
+
70
+
71
+ class RAGFindings(BaseModel):
72
+ standards: List[RetrievedStandard]
73
+ retrieved_evidence: List[EvidenceItem]
74
+ historical_matches: List[Dict[str, Any]] = Field(default_factory=list)
75
+ distribution_findings: List[str] = Field(default_factory=list)
76
+ distribution_shift: Optional[Dict[str, Any]] = None
77
+ queries: List[str] = Field(default_factory=list)
78
+
79
+
80
+ class Synthesis(BaseModel):
81
+ hypotheses: List[RootCauseHypothesis]
82
+ ai_proposed_severity: Optional[Severity]
83
+ behavioral_consequence: str
84
+ provider: str
85
+
86
+
87
+ # ----------------------------------------------------------- 1. Log Agent
88
+
89
+ def _derive_failure_type(log: Dict[str, Any]) -> Tuple[str, str]:
90
+ gt = log.get("ground_truth") or {}
91
+ pred = log.get("prediction")
92
+ planner = log.get("planner_response") or {}
93
+ if gt and pred is None:
94
+ return "FALSE_NEGATIVE", ("ground truth object present with no "
95
+ "corresponding prediction/track")
96
+ if pred and (pred.get("detection_delay_s") or 0.0) > 0.5:
97
+ return "FALSE_NEGATIVE", (f"detection arrived "
98
+ f"{pred.get('detection_delay_s')}s late — the "
99
+ "object was effectively missed during the "
100
+ "safety-relevant window (late detection)")
101
+ gt_class = (gt.get("class") or "").lower()
102
+ pred_class = (pred.get("class") or "").lower() if pred else ""
103
+ if (gt_class in NON_HAZARD_CLASSES and pred_class
104
+ and pred_class not in NON_HAZARD_CLASSES):
105
+ if (planner.get("decel_mps2") or 0.0) > 0.5:
106
+ return "FALSE_POSITIVE", (f"non-hazard '{gt_class}' predicted as "
107
+ f"'{pred_class}' and the planner "
108
+ "intervened: phantom intervention")
109
+ return "FALSE_POSITIVE", (f"non-hazard '{gt_class}' predicted as "
110
+ f"hazard '{pred_class}' (no intervention)")
111
+ if pred and gt_class and pred_class and gt_class != pred_class:
112
+ return "MISCLASSIFICATION", (f"ground truth '{gt_class}' predicted as "
113
+ f"'{pred_class}'")
114
+ return "UNKNOWN_FAILURE", "no deterministic rule matched this artifact"
115
+
116
+
117
+ def _obs(key: str, value: Any, statement: str, provenance: str) -> EvidenceItem:
118
+ return EvidenceItem(tier=EvidenceTier.OBSERVED, key=key,
119
+ value=UNKNOWN if value is None else str(value),
120
+ statement=statement, provenance=provenance)
121
+
122
+
123
+ def run_log_agent(registry: ToolRegistry, fixture_id: Optional[str] = None,
124
+ path: Optional[str] = None) -> LogExtract:
125
+ res = registry.call("log_reader",
126
+ {"fixture_id": fixture_id, "path": path})
127
+ out = res.result or {}
128
+ log = out["log"]
129
+ failure_type, rationale = _derive_failure_type(log)
130
+
131
+ gt = log.get("ground_truth") or {}
132
+ pred = log.get("prediction") or {}
133
+ ego = log.get("ego") or {}
134
+ scenario = log.get("scenario") or {}
135
+ planner = log.get("planner_response") or {}
136
+ sensors = log.get("sensor_metadata") or {}
137
+ traffic = (log.get("traffic_context") or {}).get("following_vehicle") or {}
138
+ dis = log.get("disengagement") or {}
139
+ col = log.get("collision") or {}
140
+ models = log.get("models") or {}
141
+ tracks = log.get("tracks") or []
142
+
143
+ src = "eval log field"
144
+ ev: List[EvidenceItem] = [
145
+ _obs("ego_speed_mps", ego.get("speed_mps"),
146
+ "ego speed at event time", f"{src} ego.speed_mps"),
147
+ _obs("gt_class", gt.get("class"),
148
+ "human-verified ground-truth object class", f"{src} ground_truth.class"),
149
+ _obs("gt_distance", gt.get("distance_m"),
150
+ "ground-truth object range from ego", f"{src} ground_truth.distance_m"),
151
+ _obs("gt_relative_velocity", gt.get("relative_velocity_mps"),
152
+ "ground-truth closing velocity (positive = closing)",
153
+ f"{src} ground_truth.relative_velocity_mps"),
154
+ _obs("pred_class", pred.get("class") if pred else None,
155
+ "predicted object class", f"{src} prediction.class"),
156
+ _obs("pred_confidence", pred.get("confidence") if pred else None,
157
+ "detector confidence", f"{src} prediction.confidence"),
158
+ _obs("pred_detection_delay", pred.get("detection_delay_s") if pred else None,
159
+ "detection delay versus ground-truth appearance",
160
+ f"{src} prediction.detection_delay_s"),
161
+ _obs("pred_relative_velocity", pred.get("relative_velocity_mps") if pred else None,
162
+ "predicted closing velocity", f"{src} prediction.relative_velocity_mps"),
163
+ _obs("planner_action", planner.get("action"),
164
+ "observed planner action", f"{src} planner_response.action"),
165
+ _obs("planner_decel", planner.get("decel_mps2"),
166
+ "observed planner deceleration (m/s^2)", f"{src} planner_response.decel_mps2"),
167
+ _obs("planner_response_time", planner.get("response_time_s"),
168
+ "planner response time after object appearance",
169
+ f"{src} planner_response.response_time_s"),
170
+ _obs("weather", scenario.get("weather"),
171
+ "scenario weather", f"{src} scenario.weather"),
172
+ _obs("odd_status", scenario.get("odd_status"),
173
+ "inside/outside operational design domain", f"{src} scenario.odd_status"),
174
+ _obs("following_gap", traffic.get("time_gap_s"),
175
+ "rear traffic time gap", f"{src} traffic_context.following_vehicle.time_gap_s"),
176
+ _obs("disengagement_occurred", dis.get("occurred"),
177
+ "safety-driver disengagement", f"{src} disengagement.occurred"),
178
+ _obs("collision_near_miss", col.get("near_miss"),
179
+ "near-miss recorded", f"{src} collision.near_miss"),
180
+ _obs("models", f"{models.get('baseline')} -> {models.get('candidate')}",
181
+ "baseline and candidate model versions", f"{src} models"),
182
+ ]
183
+ if tracks:
184
+ ev.append(_obs("track_class_flicker", tracks[0].get("class_flicker"),
185
+ "track-level class flicker observed", f"{src} tracks[0].class_flicker"))
186
+ ev.append(_obs("track_velocity_variance", tracks[0].get("velocity_variance"),
187
+ "track velocity variance level", f"{src} tracks[0].velocity_variance"))
188
+ if sensors:
189
+ for name in ("camera", "lidar"):
190
+ status = (sensors.get(name) or {}).get("status")
191
+ if status is not None:
192
+ ev.append(_obs(f"sensor_{name}_status", status,
193
+ f"{name} health/degradation state",
194
+ f"{src} sensor_metadata.{name}.status"))
195
+ for f in out["missing_fields"]:
196
+ ev.append(EvidenceItem(
197
+ tier=EvidenceTier.OBSERVED, key=f"missing:{f}", value=UNKNOWN,
198
+ statement=f"required field '{f}' is absent from the artifact — "
199
+ "treated as UNKNOWN, never guessed",
200
+ provenance="field absent from artifact"))
201
+
202
+ return LogExtract(log=log, source_path=out["source_path"],
203
+ present_fields=out["present_fields"],
204
+ missing_fields=out["missing_fields"],
205
+ failure_type=failure_type,
206
+ failure_type_rationale=rationale,
207
+ observed_evidence=ev)
208
+
209
+
210
+ # --------------------------------------------------- 2. Evidence Analyzer
211
+
212
+ def _drv(key: str, value: Any, statement: str, provenance: str) -> EvidenceItem:
213
+ return EvidenceItem(tier=EvidenceTier.DERIVED, key=key,
214
+ value=UNKNOWN if value is None else str(value),
215
+ statement=statement, provenance=provenance)
216
+
217
+
218
+ def run_evidence_analyzer(registry: ToolRegistry,
219
+ extract: LogExtract) -> EvidenceAnalysis:
220
+ log = extract.log
221
+ ego = log.get("ego") or {}
222
+ gt = log.get("ground_truth") or {}
223
+ timing = log.get("timing") or {}
224
+
225
+ sd_params = {
226
+ "velocity_mps": ego.get("speed_mps"),
227
+ "reaction_time_s": DEFAULT_REACTION_TIME_S,
228
+ "system_latency_s": (timing.get("detection_latency_ms") or 0) / 1000.0,
229
+ "planner_latency_s": (timing.get("planner_latency_ms") or 0) / 1000.0,
230
+ "friction": ego.get("road_friction_estimate", 0.7),
231
+ "grade": ego.get("grade", 0.0),
232
+ }
233
+ stopping = registry.call("metric_calculator", {
234
+ "operation": "stopping_distance", "params": sd_params}).result["result"]
235
+
236
+ obj_dims = gt.get("dimensions_m") or [0.6, 0.6]
237
+ ttc = registry.call("metric_calculator", {
238
+ "operation": "ttc",
239
+ "params": {"distance_m": gt.get("distance_m"),
240
+ "closing_velocity_mps": gt.get("relative_velocity_mps"),
241
+ "obj_length_m": obj_dims[0], "obj_width_m": obj_dims[1]},
242
+ }).result["result"]
243
+
244
+ scr = registry.call("metric_calculator", {
245
+ "operation": "scr_impact",
246
+ "params": {"evaluation_context": log.get("evaluation_context")},
247
+ }).result["result"]
248
+
249
+ behavioral = registry.call("metric_calculator", {
250
+ "operation": "behavioral_impact",
251
+ "params": {"observed": log.get("planner_response"),
252
+ "counterfactual": log.get("counterfactual_planner_response")},
253
+ }).result["result"]
254
+
255
+ prov = "MetricCalculatorTool (deterministic)"
256
+ ev: List[EvidenceItem] = []
257
+ unknown: List[str] = []
258
+
259
+ if stopping.get("stopping_distance_m") is not None:
260
+ ev.append(_drv("stopping_distance", stopping["stopping_distance_m"],
261
+ f"stopping distance {stopping['stopping_distance_m']} m "
262
+ f"(reaction {stopping['reaction_distance_m']} m + braking "
263
+ f"{stopping['braking_distance_m']} m at "
264
+ f"{stopping['effective_decel_mps2']} m/s^2 effective decel; "
265
+ f"total reaction budget {stopping['total_reaction_time_s']} s "
266
+ "incl. perception+planner latency)",
267
+ f"{prov} stopping_distance"))
268
+ if gt.get("distance_m") is not None:
269
+ ratio = gt["distance_m"] / stopping["stopping_distance_m"]
270
+ ev.append(_drv("distance_vs_stopping", round(ratio, 3),
271
+ f"object range {gt['distance_m']} m is {ratio:.2f}x "
272
+ "the stopping distance -> "
273
+ + ("INSIDE the stopping envelope"
274
+ if ratio <= 1.0 else
275
+ ("inside 1.5x criticality envelope" if ratio <= 1.5
276
+ else "outside the criticality envelope")),
277
+ f"{prov} stopping_distance + observed range"))
278
+ else:
279
+ unknown.append("stopping_distance")
280
+ ev.append(_drv("stopping_distance", None,
281
+ f"stopping distance UNKNOWN: {stopping.get('unknown_reason')}",
282
+ f"{prov} stopping_distance"))
283
+
284
+ if ttc.get("ttc_s") is not None:
285
+ ev.append(_drv("ttc", ttc["ttc_s"],
286
+ f"projected TTC {ttc['ttc_s']} s (SSAM rectangle "
287
+ f"projection; closed-form cross-check "
288
+ f"{ttc.get('closed_form_ttc_s')} s); validity: "
289
+ + "; ".join(ttc.get("validity_flags", [])),
290
+ f"{prov} ttc"))
291
+ else:
292
+ if ttc.get("unknown_reason"):
293
+ unknown.append("ttc")
294
+ ev.append(_drv("ttc", None,
295
+ f"TTC UNKNOWN: {ttc['unknown_reason']}", f"{prov} ttc"))
296
+ else:
297
+ ev.append(_drv("ttc", "no_predicted_collision",
298
+ "no projected collision path (object not closing or "
299
+ "beyond look-ahead); validity: "
300
+ + "; ".join(ttc.get("validity_flags", [])),
301
+ f"{prov} ttc"))
302
+
303
+ if scr.get("scr_impact") is not None:
304
+ ev.append(_drv("scr_impact", scr["scr_impact"],
305
+ f"safety-critical recall {scr['scr_baseline']:.4f} -> "
306
+ f"{scr['scr_candidate']:.4f} (delta {scr['scr_impact']:+.4f} "
307
+ f"over {scr['denominator']} critical objects; criticality: "
308
+ f"{scr['criticality_policy']})",
309
+ f"{prov} scr_impact"))
310
+ else:
311
+ unknown.append("safety_critical_recall_impact")
312
+ ev.append(_drv("scr_impact", None,
313
+ f"SCR impact UNKNOWN: {scr.get('unknown_reason')}",
314
+ f"{prov} scr_impact"))
315
+
316
+ if behavioral.get("consequence") not in (None, "UNKNOWN"):
317
+ ev.append(_drv("behavioral_impact", behavioral.get("decel_delta_mps2"),
318
+ f"behavioral impact: {behavioral['consequence']}",
319
+ f"{prov} behavioral_impact (observed vs corrected-"
320
+ "perception counterfactual)"))
321
+ else:
322
+ unknown.append("behavioral_impact")
323
+ ev.append(_drv("behavioral_impact", None,
324
+ f"behavioral impact UNKNOWN: {behavioral.get('unknown_reason')}",
325
+ f"{prov} behavioral_impact"))
326
+
327
+ return EvidenceAnalysis(stopping=stopping, ttc=ttc, scr=scr,
328
+ behavioral=behavioral, derived_evidence=ev,
329
+ unknown_metrics=unknown)
330
+
331
+
332
+ # ---------------------------------------------------------- 3. RAG Agent
333
+
334
+ def _build_queries(extract: LogExtract) -> List[str]:
335
+ log = extract.log
336
+ gt_class = ((log.get("ground_truth") or {}).get("class") or "object").lower()
337
+ weather = ((log.get("scenario") or {}).get("weather") or "").lower()
338
+ queries: List[str] = []
339
+ if extract.failure_type == "FALSE_NEGATIVE":
340
+ queries.append(f"{gt_class} missed detection inside stopping distance "
341
+ "recall requirement")
342
+ if weather in ("rain", "snow", "fog"):
343
+ queries.append(f"{weather} degradation detection recall confidence "
344
+ "threshold fusion")
345
+ elif extract.failure_type == "FALSE_POSITIVE":
346
+ queries.append(f"phantom braking false positive {gt_class} road debris "
347
+ "misclassification limits")
348
+ queries.append("asymmetric false negative false positive cost context")
349
+ else:
350
+ queries.append(f"{gt_class} misclassification requirements "
351
+ "class stability")
352
+ queries.append("launch criteria severity gate insufficient evidence")
353
+ return queries
354
+
355
+
356
+ def run_rag_agent(registry: ToolRegistry, extract: LogExtract,
357
+ extra_queries: Optional[List[str]] = None,
358
+ k: int = 3) -> RAGFindings:
359
+ queries = _build_queries(extract) + list(extra_queries or [])
360
+ seen: Dict[str, RetrievedStandard] = {}
361
+ for q in queries:
362
+ res = registry.call("safety_standard_rag", {"query": q, "k": k})
363
+ for hit in (res.result or {}).get("hits", []):
364
+ std = RetrievedStandard(**hit)
365
+ if std.chunk_id not in seen or std.relevance_score > seen[std.chunk_id].relevance_score:
366
+ seen[std.chunk_id] = std
367
+ standards = sorted(seen.values(), key=lambda s: -s.relevance_score)[:8]
368
+
369
+ retrieved_ev = [EvidenceItem(
370
+ tier=EvidenceTier.RETRIEVED, key=f"standard:{s.doc_id}:{s.chunk_id}",
371
+ value=f"relevance {s.relevance_score:.2f}",
372
+ statement=f"[{s.label}] {s.document} v{s.version} §{s.section}: "
373
+ f"{s.retrieved_text[:180]}…",
374
+ provenance=f"SafetyStandardRAGTool hit {s.chunk_id} "
375
+ f"(source {s.source})") for s in standards]
376
+
377
+ hist = registry.call("historical_failure_search", {
378
+ "query_text": f"{extract.failure_type} "
379
+ f"{json.dumps((extract.log.get('scenario') or {}))[:300]}",
380
+ "k": 3, "exclude_evaluation_id": extract.log.get("evaluation_id"),
381
+ }).result or {}
382
+
383
+ dist = registry.call("distribution_analysis", {
384
+ "distribution_shift": (extract.log.get("evaluation_context") or {})
385
+ .get("distribution_shift"),
386
+ }).result or {}
387
+ for m in hist.get("matches", []):
388
+ retrieved_ev.append(EvidenceItem(
389
+ tier=EvidenceTier.RETRIEVED, key=f"historical:{m['evaluation_id']}",
390
+ value=f"similarity {m['similarity']:.2f}",
391
+ statement=f"prior analyzed failure {m['evaluation_id']} "
392
+ f"({m.get('failure_type')}, {m.get('severity')}) is similar",
393
+ provenance="HistoricalFailureSearchTool"))
394
+ for f in dist.get("findings", []):
395
+ retrieved_ev.append(EvidenceItem(
396
+ tier=EvidenceTier.RETRIEVED, key="distribution_finding",
397
+ value=None, statement=f,
398
+ provenance=f"DistributionAnalysisTool ({dist.get('source')})"))
399
+
400
+ return RAGFindings(standards=standards, retrieved_evidence=retrieved_ev,
401
+ historical_matches=hist.get("matches", []),
402
+ distribution_findings=dist.get("findings", []),
403
+ distribution_shift=dist.get("shift"),
404
+ queries=queries)
405
+
406
+
407
+ # -------------------------------------------------- 4. Safety Synthesizer
408
+
409
+ def _extract_json(text: str) -> Optional[Dict[str, Any]]:
410
+ m = re.search(r"\{.*\}", text, re.DOTALL)
411
+ if not m:
412
+ return None
413
+ try:
414
+ return json.loads(m.group(0))
415
+ except json.JSONDecodeError:
416
+ return None
417
+
418
+
419
+ def run_synthesizer(backend: InferenceBackend, registry: ToolRegistry,
420
+ extract: LogExtract, analysis: EvidenceAnalysis,
421
+ rag: RAGFindings) -> Tuple[Synthesis, RAGFindings]:
422
+ """Returns the synthesis plus a possibly-augmented RAGFindings (the LLM
423
+ may request up to two follow-up retrieval queries, ReAct-style)."""
424
+ scripted = Synthesis(
425
+ hypotheses=scripted_hypotheses(extract.failure_type, extract.log,
426
+ extract.missing_fields),
427
+ ai_proposed_severity=scripted_severity_proposal(extract.log,
428
+ extract.failure_type),
429
+ behavioral_consequence=analysis.behavioral.get("consequence", UNKNOWN),
430
+ provider="scripted-deterministic")
431
+
432
+ if backend.name == "mock":
433
+ return scripted, rag
434
+
435
+ prompt = synthesis_prompt(
436
+ extract.failure_type,
437
+ [e.model_dump() for e in extract.observed_evidence],
438
+ [e.model_dump() for e in analysis.derived_evidence],
439
+ [s.model_dump() for s in rag.standards],
440
+ extract.missing_fields)
441
+ try:
442
+ res = backend.generate(prompt, system=SYSTEM_PROMPT, max_tokens=1400)
443
+ parsed = _extract_json(res.text)
444
+ except Exception:
445
+ parsed = None
446
+ if not parsed or not parsed.get("hypotheses"):
447
+ # Honest fallback: model unreachable/unparseable -> scripted analysis,
448
+ # provider label says so.
449
+ scripted.provider = f"{backend.name}-fallback-scripted"
450
+ return scripted, rag
451
+
452
+ hyps: List[RootCauseHypothesis] = []
453
+ for h in parsed["hypotheses"][:4]:
454
+ try:
455
+ hyps.append(RootCauseHypothesis(
456
+ hypothesis=str(h.get("hypothesis", ""))[:600],
457
+ confidence=max(0.0, min(1.0, float(h.get("confidence", 0.5)))),
458
+ supporting_evidence_keys=[str(k) for k in
459
+ h.get("supporting_evidence_keys", [])][:8],
460
+ missing_evidence=[str(k) for k in
461
+ h.get("missing_evidence", [])][:8]))
462
+ except (TypeError, ValueError):
463
+ continue
464
+ sev = None
465
+ try:
466
+ sev = Severity(str(parsed.get("proposed_severity", "")).upper())
467
+ except ValueError:
468
+ pass
469
+
470
+ extra_q = [str(q) for q in parsed.get("additional_queries", [])][:2]
471
+ if extra_q:
472
+ rag = run_rag_agent(registry, extract, extra_queries=extra_q)
473
+
474
+ model_label = getattr(backend, "model", None) or backend.name
475
+ return Synthesis(
476
+ hypotheses=hyps or scripted.hypotheses,
477
+ ai_proposed_severity=sev or scripted.ai_proposed_severity,
478
+ behavioral_consequence=str(
479
+ parsed.get("behavioral_consequence")
480
+ or analysis.behavioral.get("consequence", UNKNOWN))[:500],
481
+ provider=f"{backend.name}:{model_label}"), rag
482
+
483
+
484
+ # ------------------------------------------------------------- assembly
485
+
486
+ def _assemble_scorecard(extract: LogExtract, analysis: EvidenceAnalysis,
487
+ rag: RAGFindings, synth: Synthesis,
488
+ backend_name: str) -> RetrospectiveScorecard:
489
+ log = extract.log
490
+ ego = log.get("ego") or {}
491
+ gt = log.get("ground_truth") or {}
492
+ pred = log.get("prediction")
493
+ ec = log.get("evaluation_context") or {}
494
+ dis = log.get("disengagement") or {}
495
+ traffic = ((log.get("traffic_context") or {}).get("following_vehicle") or {})
496
+
497
+ sev_ctx = SeverityContext(
498
+ failure_type=extract.failure_type,
499
+ object_class=gt.get("class"),
500
+ distance_m=gt.get("distance_m"),
501
+ stopping_distance_m=analysis.stopping.get("stopping_distance_m"),
502
+ closing_velocity_mps=gt.get("relative_velocity_mps"),
503
+ ttc_s=analysis.ttc.get("ttc_s"),
504
+ total_reaction_time_s=analysis.stopping.get("total_reaction_time_s"),
505
+ collision_occurred=bool((log.get("collision") or {}).get("occurred")),
506
+ near_miss=bool((log.get("collision") or {}).get("near_miss")),
507
+ intervention_decel_mps2=(log.get("planner_response") or {}).get("decel_mps2"),
508
+ following_gap_s=traffic.get("time_gap_s"),
509
+ ego_speed_mps=ego.get("speed_mps"))
510
+ sev_result = compute_severity(sev_ctx)
511
+ adjudication = adjudicate_severity(sev_result, synth.ai_proposed_severity)
512
+
513
+ uncertainty = UncertaintyReport(
514
+ missing_fields=extract.missing_fields,
515
+ unknown_metrics=analysis.unknown_metrics,
516
+ notes=([f"severity adjudication: {adjudication.divergence_note}"]
517
+ if adjudication.divergence else []))
518
+
519
+ sig_raw = ec.get("significance")
520
+ significance = (StatSignificance(**sig_raw) if sig_raw else
521
+ StatSignificance(method="seqeval.PairedSequentialTest",
522
+ significant=None,
523
+ detail="no paired-run significance artifact "
524
+ "attached — not evaluable here"))
525
+
526
+ decision = launch_gate(
527
+ severity=adjudication.final_severity,
528
+ has_evidence_gaps=uncertainty.has_gaps,
529
+ scr_impact=analysis.scr.get("scr_impact"),
530
+ scr_significant=significance.significant)
531
+
532
+ policy_ev = [EvidenceItem(
533
+ tier=EvidenceTier.DERIVED, key="policy_severity",
534
+ value=adjudication.final_severity.value,
535
+ statement="deterministic severity: " + " | ".join(sev_result.rule_trace),
536
+ provenance=f"policy engine {POLICY_VERSION} (compute_severity)")]
537
+ if adjudication.divergence:
538
+ policy_ev.append(EvidenceItem(
539
+ tier=EvidenceTier.DERIVED, key="severity_divergence",
540
+ value=f"AI={adjudication.ai_proposed_severity.value} "
541
+ f"policy={adjudication.final_severity.value}",
542
+ statement=adjudication.divergence_note,
543
+ provenance=f"policy engine {POLICY_VERSION} (adjudicate_severity)"))
544
+
545
+ hypothesis_ev = [EvidenceItem(
546
+ tier=EvidenceTier.AI_HYPOTHESIS, key=f"hypothesis_{i + 1}",
547
+ value=f"confidence {h.confidence:.0%}",
548
+ statement=h.hypothesis,
549
+ provenance=f"Safety Synthesizer ({synth.provider}) — inference, not fact")
550
+ for i, h in enumerate(synth.hypotheses)]
551
+
552
+ review_reasons: List[str] = []
553
+ if adjudication.final_severity in (Severity.CRITICAL, Severity.FATAL):
554
+ review_reasons.append(f"severity {adjudication.final_severity.value}")
555
+ if adjudication.divergence:
556
+ review_reasons.append("AI severity proposal diverged from policy")
557
+ if uncertainty.has_gaps:
558
+ review_reasons.append("required evidence UNKNOWN")
559
+ if len(rag.standards) < 2:
560
+ review_reasons.append("fewer than two independent retrieved sources")
561
+
562
+ return RetrospectiveScorecard(
563
+ evaluation_id=log.get("evaluation_id", "UNKNOWN-EVAL"),
564
+ policy_version=POLICY_VERSION,
565
+ agent_version=AGENT_VERSION,
566
+ backend_used=synth.provider if backend_name != "mock" else "mock",
567
+ failure_type=extract.failure_type,
568
+ severity=adjudication.final_severity,
569
+ ai_proposed_severity=adjudication.ai_proposed_severity,
570
+ severity_divergence=adjudication.divergence,
571
+ safety_critical_recall_impact=analysis.scr.get("scr_impact"),
572
+ scr_impact_detail=analysis.scr.get("unknown_reason")
573
+ or analysis.scr.get("criticality_policy", ""),
574
+ behavioral_consequence=synth.behavioral_consequence,
575
+ launch_recommendation=decision.recommendation,
576
+ launch_rationale=decision.rationale,
577
+ baseline_model=(log.get("models") or {}).get("baseline"),
578
+ candidate_model=(log.get("models") or {}).get("candidate"),
579
+ scenario=log.get("scenario") or {},
580
+ object_class=gt.get("class"),
581
+ ground_truth=gt or None,
582
+ prediction=pred,
583
+ confidence=(pred or {}).get("confidence") if pred else None,
584
+ ego_speed_mps=ego.get("speed_mps"),
585
+ distance_to_object_m=gt.get("distance_m"),
586
+ relative_velocity_mps=gt.get("relative_velocity_mps"),
587
+ stopping_distance_m=analysis.stopping.get("stopping_distance_m"),
588
+ ttc_s=analysis.ttc.get("ttc_s"),
589
+ ttc_validity=analysis.ttc.get("validity_flags", []),
590
+ planner_response=log.get("planner_response"),
591
+ disengagement_probability=dis.get("probability_model"),
592
+ metric_delta=ec.get("metric_delta"),
593
+ statistical_significance=significance,
594
+ distribution_shift=rag.distribution_shift,
595
+ root_cause_hypotheses=synth.hypotheses,
596
+ evidence=(extract.observed_evidence + analysis.derived_evidence
597
+ + rag.retrieved_evidence + policy_ev + hypothesis_ev),
598
+ retrieved_standards=rag.standards,
599
+ uncertainty=uncertainty,
600
+ human_review_required=bool(review_reasons),
601
+ human_review_reasons=review_reasons)
602
+
603
+
604
+ # ------------------------------------------------------------ entrypoints
605
+
606
+ def analyze(fixture_id: Optional[str] = None, path: Optional[str] = None,
607
+ backend: str = "mock", persist: bool = True,
608
+ registry: Optional[ToolRegistry] = None) -> RetrospectiveScorecard:
609
+ """Full pipeline on a fixture id or an allowlisted artifact path."""
610
+ be = get_backend(backend)
611
+ if be.name != "mock":
612
+ status = be.health()
613
+ if not status.available:
614
+ raise RuntimeError(f"backend '{backend}' unavailable: {status.detail}")
615
+
616
+ analysis_key = fixture_id or (path or "adhoc").rsplit("/", 1)[-1].replace(".json", "")
617
+ reg = registry or build_registry(analysis_id=analysis_key,
618
+ persist_audit=persist)
619
+
620
+ extract = run_log_agent(reg, fixture_id=fixture_id, path=path)
621
+ analysis = run_evidence_analyzer(reg, extract)
622
+ rag = run_rag_agent(reg, extract)
623
+ synth, rag = run_synthesizer(be, reg, extract, analysis, rag)
624
+ scorecard = _assemble_scorecard(extract, analysis, rag, synth, be.name)
625
+
626
+ if persist:
627
+ store.save_analysis(scorecard.evaluation_id, {
628
+ "scorecard": json.loads(scorecard.model_dump_json()),
629
+ "markdown": scorecard.render_markdown(),
630
+ "audit_analysis_id": reg.analysis_id,
631
+ "stage_summary": {
632
+ "failure_type_rationale": extract.failure_type_rationale,
633
+ "rag_queries": rag.queries,
634
+ "synthesis_provider": synth.provider,
635
+ },
636
+ })
637
+ return scorecard
638
+
639
+
640
+ def analyze_fixture(fixture_id: str, backend: str = "mock",
641
+ persist: bool = True) -> RetrospectiveScorecard:
642
+ return analyze(fixture_id=fixture_id, backend=backend, persist=persist)
sensorflow/retro/agent/prompts.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompts for the LLM-backed synthesis stage (Ollama backend)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, List
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are the Safety Synthesizer stage of a retrospective analyzer for an "
10
+ "autonomous-vehicle perception platform. You interpret evidence and form "
11
+ "hypotheses. You NEVER compute metrics, NEVER invent telemetry, and NEVER "
12
+ "make launch determinations — deterministic code owns those. Separate "
13
+ "fact from inference rigorously: anything not present in the supplied "
14
+ "evidence must be listed as missing_evidence, not asserted."
15
+ )
16
+
17
+
18
+ def synthesis_prompt(failure_type: str, observed: List[Dict[str, Any]],
19
+ derived: List[Dict[str, Any]],
20
+ retrieved: List[Dict[str, Any]],
21
+ missing_fields: List[str]) -> str:
22
+ return f"""FAILURE TYPE (deterministically derived): {failure_type}
23
+
24
+ TIER1 OBSERVED FACTS (from the artifact):
25
+ {json.dumps(observed, indent=1)[:3000]}
26
+
27
+ TIER2 DERIVED METRICS (deterministic calculators):
28
+ {json.dumps(derived, indent=1)[:2500]}
29
+
30
+ TIER3 RETRIEVED REQUIREMENTS (safety-case retrieval; synthetic-labeled corpus):
31
+ {json.dumps([{k: r[k] for k in ('doc_id', 'section', 'retrieved_text')}
32
+ for r in retrieved], indent=1)[:2500]}
33
+
34
+ MISSING ARTIFACT FIELDS (must stay UNKNOWN): {missing_fields}
35
+
36
+ Respond with ONLY a JSON object:
37
+ {{
38
+ "hypotheses": [
39
+ {{"hypothesis": "<causal hypothesis, clearly inference>",
40
+ "confidence": <0..1>,
41
+ "supporting_evidence_keys": ["<keys from the observed/derived items>"],
42
+ "missing_evidence": ["<what you would need to confirm>"]}}
43
+ ],
44
+ "proposed_severity": "BENIGN|DISRUPTIVE|CRITICAL|FATAL",
45
+ "behavioral_consequence": "<one sentence, grounded in the derived metrics>",
46
+ "additional_queries": ["<up to 2 follow-up safety-case retrieval queries>"]
47
+ }}
48
+ Base every claim on the supplied evidence. 2-4 hypotheses."""
sensorflow/retro/agent/scripted.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic scripted synthesis (mock backend path).
2
+
3
+ Rule-driven hypothesis generation assembled from the ACTUAL evidence in the
4
+ artifact — the same pattern as the platform's offline MITL copilot
5
+ (sensorflow/evaluation/copilot.py). Never invents telemetry: hypotheses cite
6
+ supporting evidence keys and explicitly list the evidence that is missing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from sensorflow.retro.scorecard import RootCauseHypothesis, Severity
14
+
15
+
16
+ def scripted_hypotheses(failure_type: str, log: Dict[str, Any],
17
+ missing_fields: List[str]) -> List[RootCauseHypothesis]:
18
+ gt = log.get("ground_truth") or {}
19
+ pred = log.get("prediction") or {}
20
+ scenario = log.get("scenario") or {}
21
+ sensors = log.get("sensor_metadata") or {}
22
+ tracks = log.get("tracks") or []
23
+ hyps: List[RootCauseHypothesis] = []
24
+
25
+ gt_class = (gt.get("class") or "").lower()
26
+ weather = (scenario.get("weather") or "").lower()
27
+ flicker = any(t.get("class_flicker") for t in tracks)
28
+
29
+ if failure_type == "FALSE_POSITIVE" and gt_class in (
30
+ "plastic_bag", "debris", "cardboard", "balloon"):
31
+ hyps.append(RootCauseHypothesis(
32
+ hypothesis="Training-distribution gap for wind-blown deformable "
33
+ "low-mass objects: the detector assigned a hazardous "
34
+ "class with high confidence to a non-hazard object, "
35
+ "consistent with the historical phantom-braking cluster "
36
+ "(RETRO-2025-014 [SYNTHETIC]).",
37
+ confidence=0.65,
38
+ supporting_evidence_keys=["gt_class", "pred_class", "pred_confidence"]
39
+ + (["track_class_flicker"] if flicker else []),
40
+ missing_evidence=["camera embeddings for the misclassified frames",
41
+ "training-set frequency of deformable clutter"]))
42
+ if "velocity_note" in pred or any(
43
+ t.get("velocity_variance") == "high" for t in tracks):
44
+ hyps.append(RootCauseHypothesis(
45
+ hypothesis="Tracker velocity latch: bag flutter was integrated "
46
+ "into a spurious closing velocity, converting a "
47
+ "static-clutter FP into a phantom emergency brake.",
48
+ confidence=0.55,
49
+ supporting_evidence_keys=["pred_relative_velocity",
50
+ "gt_relative_velocity",
51
+ "track_velocity_variance"],
52
+ missing_evidence=["raw radar doppler track for the object"]))
53
+
54
+ elif failure_type == "FALSE_NEGATIVE" and gt_class in (
55
+ "pedestrian", "cyclist", "motorcycle", "wheelchair"):
56
+ if weather in ("rain", "snow", "fog"):
57
+ cam = (sensors.get("camera") or {}).get("status", "")
58
+ lid = (sensors.get("lidar") or {}).get("status", "")
59
+ hyps.append(RootCauseHypothesis(
60
+ hypothesis=f"Fusion confidence starvation under {weather}: "
61
+ f"camera '{cam}' and LiDAR '{lid}' each degraded, "
62
+ "and the fusion confidence stayed below the tracking "
63
+ "threshold instead of applying weather-conditioned "
64
+ "weighting (pattern matches RETRO-2025-021 "
65
+ "[SYNTHETIC]).",
66
+ confidence=0.6,
67
+ supporting_evidence_keys=["sensor_camera_status",
68
+ "sensor_lidar_status",
69
+ "pred_confidence",
70
+ "pred_detection_delay"],
71
+ missing_evidence=["per-frame fusion confidence trace",
72
+ "confidence threshold config for this release"]))
73
+ hyps.append(RootCauseHypothesis(
74
+ hypothesis="Training-data coverage gap for this VRU appearance/"
75
+ "condition slice; the distribution-shift slice data "
76
+ "shows the regression concentrated rather than uniform.",
77
+ confidence=0.5,
78
+ supporting_evidence_keys=["distribution_shift", "metric_delta"],
79
+ missing_evidence=["slice-level training data counts"]))
80
+
81
+ elif failure_type == "FALSE_POSITIVE":
82
+ hyps.append(RootCauseHypothesis(
83
+ hypothesis="Static-scene classification ambiguity: a fixed roadside "
84
+ "asset was transiently classified as a dynamic hazard; "
85
+ "map-prior fusion did not veto the detection.",
86
+ confidence=0.5,
87
+ supporting_evidence_keys=["gt_class", "pred_class",
88
+ "pred_confidence"],
89
+ missing_evidence=["map-prior veto decision log"]))
90
+
91
+ elif failure_type == "FALSE_NEGATIVE":
92
+ hyps.append(RootCauseHypothesis(
93
+ hypothesis="Detection miss on a low-salience static object; range "
94
+ "sparsity is the most consistent factor given nominal "
95
+ "sensor status.",
96
+ confidence=0.45,
97
+ supporting_evidence_keys=["gt_distance", "sensor_lidar_status"],
98
+ missing_evidence=["point count inside the ground-truth box"]))
99
+
100
+ if not hyps:
101
+ hyps.append(RootCauseHypothesis(
102
+ hypothesis="Insufficient patterns matched for a specific causal "
103
+ "hypothesis; requires human investigation.",
104
+ confidence=0.3, supporting_evidence_keys=[],
105
+ missing_evidence=["additional telemetry"]))
106
+
107
+ if missing_fields:
108
+ for h in hyps:
109
+ for f in missing_fields:
110
+ marker = f"artifact field {f}"
111
+ if marker not in h.missing_evidence:
112
+ h.missing_evidence.append(marker)
113
+ return hyps
114
+
115
+
116
+ def scripted_severity_proposal(log: Dict[str, Any],
117
+ failure_type: str) -> Optional[Severity]:
118
+ """The mock 'LLM proposal' — a deliberately simpler heuristic than the
119
+ policy engine, so the policy-validate/override flow is real."""
120
+ collision = (log.get("collision") or {})
121
+ planner = (log.get("planner_response") or {})
122
+ traffic = ((log.get("traffic_context") or {}).get("following_vehicle") or {})
123
+ if collision.get("occurred"):
124
+ return Severity.FATAL
125
+ if collision.get("near_miss"):
126
+ return Severity.CRITICAL
127
+ decel = planner.get("decel_mps2") or 0.0
128
+ gap = traffic.get("time_gap_s")
129
+ if decel >= 3.4 and gap is not None and gap < 1.5:
130
+ return Severity.CRITICAL
131
+ if decel >= 3.0:
132
+ return Severity.DISRUPTIVE
133
+ return Severity.BENIGN
sensorflow/retro/api.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI router for the Agentic Retrospective Safety Analyzer.
2
+
3
+ Mounted at /api/retro by app_backend.py. Artifacts persist under runs/retro/.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ from fastapi import APIRouter, Body, HTTPException, Query
13
+
14
+ from sensorflow.retro import store
15
+ from sensorflow.retro.agent.orchestrator import analyze
16
+ from sensorflow.retro.inference.client import all_backend_statuses
17
+ from sensorflow.retro.inference.compat import check_vllm_compatibility, format_report
18
+ from sensorflow.retro.inference.env_detect import detect_environment
19
+ from sensorflow.retro.rag.evalharness import run_retrieval_eval
20
+ from sensorflow.retro.rag.retriever import get_index
21
+ from sensorflow.retro.tools.builtin import FIXTURES_DIR, build_registry
22
+
23
+ router = APIRouter(prefix="/api/retro", tags=["retro"])
24
+
25
+
26
+ @router.get("/env")
27
+ def env_report():
28
+ env = detect_environment()
29
+ return {"status": "ok", "environment": env.model_dump()}
30
+
31
+
32
+ @router.get("/compat")
33
+ def compat_report(model_name: str = "meta-llama/Llama-3.1-8B-Instruct",
34
+ quantization: str = "none"):
35
+ env = detect_environment(probe_ollama=False)
36
+ report = check_vllm_compatibility(env, model_name=model_name,
37
+ quantization=quantization)
38
+ return {"status": "ok", "report": report.model_dump(),
39
+ "formatted": format_report(report)}
40
+
41
+
42
+ @router.get("/backends")
43
+ def backends_status():
44
+ return {"status": "ok",
45
+ "backends": [s.model_dump() for s in all_backend_statuses()]}
46
+
47
+
48
+ @router.get("/rag/search")
49
+ def rag_search(q: str = Query(min_length=3), k: int = Query(4, ge=1, le=10)):
50
+ index = get_index()
51
+ hits = index.search(q, k=k)
52
+ return {"status": "ok", "store_backend": index.backend_name,
53
+ "hits": [h.model_dump() for h in hits]}
54
+
55
+
56
+ @router.get("/rag/eval")
57
+ def rag_eval(k: int = Query(4, ge=1, le=10)):
58
+ return {"status": "ok", "report": run_retrieval_eval(k=k).model_dump()}
59
+
60
+
61
+ @router.get("/fixtures")
62
+ def list_fixtures():
63
+ out = []
64
+ for p in sorted(Path(FIXTURES_DIR).glob("*.json")):
65
+ try:
66
+ data = json.loads(p.read_text())
67
+ out.append({
68
+ "fixture_id": p.stem,
69
+ "evaluation_id": data.get("evaluation_id"),
70
+ "description": (data.get("scenario") or {}).get("description", ""),
71
+ "weather": (data.get("scenario") or {}).get("weather"),
72
+ })
73
+ except (json.JSONDecodeError, OSError):
74
+ continue
75
+ return {"status": "ok", "fixtures": out}
76
+
77
+
78
+ @router.post("/analyze")
79
+ def analyze_failure(fixture_id: Optional[str] = None,
80
+ backend: str = Query("mock", pattern="^(mock|ollama|vllm)$"),
81
+ log: Optional[Dict[str, Any]] = Body(None)):
82
+ """Analyze a fixture by id, or an uploaded JSON eval log in the body."""
83
+ if not fixture_id and not log:
84
+ raise HTTPException(status_code=400,
85
+ detail="provide ?fixture_id= or a JSON log body")
86
+ try:
87
+ if fixture_id:
88
+ scorecard = analyze(fixture_id=fixture_id, backend=backend)
89
+ else:
90
+ eval_id = log.get("evaluation_id", "uploaded")
91
+ safe = "".join(c for c in eval_id if c.isalnum() or c in "-_")[:64]
92
+ upload_path = store.uploads_dir() / f"{safe or 'uploaded'}.json"
93
+ upload_path.write_text(json.dumps(log, indent=2))
94
+ scorecard = analyze(path=str(upload_path), backend=backend)
95
+ except FileNotFoundError as exc:
96
+ raise HTTPException(status_code=404, detail=str(exc))
97
+ except RuntimeError as exc: # backend unavailable
98
+ raise HTTPException(status_code=503, detail=str(exc))
99
+ return {"status": "ok",
100
+ "scorecard": json.loads(scorecard.model_dump_json()),
101
+ "markdown": scorecard.render_markdown()}
102
+
103
+
104
+ @router.get("/analyses")
105
+ def list_analyses():
106
+ return {"status": "ok", "analyses": store.list_analyses()}
107
+
108
+
109
+ @router.get("/analyses/{evaluation_id}")
110
+ def get_analysis(evaluation_id: str):
111
+ data = store.load_analysis(evaluation_id)
112
+ if data is None:
113
+ raise HTTPException(status_code=404,
114
+ detail=f"no analysis for {evaluation_id}")
115
+ return {"status": "ok", **data}
116
+
117
+
118
+ @router.get("/analyses/{evaluation_id}/audit")
119
+ def get_audit(evaluation_id: str):
120
+ data = store.load_analysis(evaluation_id)
121
+ if data is None:
122
+ raise HTTPException(status_code=404,
123
+ detail=f"no analysis for {evaluation_id}")
124
+ audit_id = data.get("audit_analysis_id", evaluation_id)
125
+ return {"status": "ok", "audit_analysis_id": audit_id,
126
+ "records": store.read_audit(audit_id)}
127
+
128
+
129
+ @router.get("/tools")
130
+ def tool_registry_listing():
131
+ reg = build_registry(persist_audit=False)
132
+ return {"status": "ok",
133
+ "tools": [s.model_dump() for s in reg.specs()],
134
+ "note": "write tools require policy_authorization=true per call; "
135
+ "all calls are audited"}
sensorflow/retro/fixtures/benign_fp_static_sign.json ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "evaluation_id": "EVAL-2026-0805-BF01",
3
+ "artifact_kind": "perception_failure_eval_log",
4
+ "scenario": {
5
+ "scenario_id": "SCN-SUB-207-A",
6
+ "description": "Suburban street, clear day. A roadside sign at 55 m is briefly detected as a stopped vehicle in the shoulder; planner applies a gentle precautionary slowdown; no following traffic. Benign false positive.",
7
+ "road_type": "suburban_street",
8
+ "weather": "clear",
9
+ "rain_rate_mmh": 0.0,
10
+ "time_of_day": "day",
11
+ "odd_status": "inside",
12
+ "posted_speed_mps": 13.4
13
+ },
14
+ "models": {
15
+ "baseline": "percept-bev-v41.2",
16
+ "candidate": "percept-bev-v42.0"
17
+ },
18
+ "sensor_metadata": {
19
+ "camera": {"status": "nominal", "fps": 30},
20
+ "lidar": {"status": "nominal", "returns_density": "normal", "fps": 10},
21
+ "radar": {"status": "nominal", "note": "static return, zero doppler"}
22
+ },
23
+ "ego": {
24
+ "speed_mps": 12.0,
25
+ "heading_deg": 0.0,
26
+ "grade": 0.0,
27
+ "road_friction_estimate": 0.8,
28
+ "lane": 1
29
+ },
30
+ "ground_truth": {
31
+ "object_id": "gt-1180",
32
+ "class": "traffic_sign",
33
+ "distance_m": 55.0,
34
+ "lateral_offset_m": 3.2,
35
+ "relative_velocity_mps": 0.0,
36
+ "dimensions_m": [0.1, 0.8, 2.2],
37
+ "source": "map-verified static asset"
38
+ },
39
+ "prediction": {
40
+ "object_id": "det-45520",
41
+ "class": "vehicle",
42
+ "confidence": 0.58,
43
+ "distance_m": 54.6,
44
+ "relative_velocity_mps": 0.0,
45
+ "track_id": "trk-902",
46
+ "class_history": ["vehicle", "vehicle", "traffic_sign"],
47
+ "detection_delay_s": 0.0,
48
+ "note": "resolved to traffic_sign after 3 frames"
49
+ },
50
+ "tracks": [
51
+ {"track_id": "trk-902", "frames": 3, "class_flicker": true,
52
+ "velocity_variance": "none", "id_switches": 0}
53
+ ],
54
+ "timing": {
55
+ "detection_latency_ms": 110,
56
+ "planner_latency_ms": 70
57
+ },
58
+ "planner_response": {
59
+ "action": "precautionary_slowdown",
60
+ "decel_mps2": 1.2,
61
+ "response_time_s": 0.6
62
+ },
63
+ "counterfactual_planner_response": {
64
+ "action": "maintain_speed",
65
+ "decel_mps2": 0.0,
66
+ "response_time_s": 0.6,
67
+ "source": "resim replay with corrected static-sign classification"
68
+ },
69
+ "traffic_context": {
70
+ "following_vehicle": {"present": true, "time_gap_s": 4.2, "speed_mps": 11.8}
71
+ },
72
+ "disengagement": {"occurred": false, "probability_model": 0.03},
73
+ "collision": {"occurred": false, "near_miss": false},
74
+ "evaluation_context": {
75
+ "critical_object_count": 1240,
76
+ "baseline_missed_critical": 3,
77
+ "candidate_missed_critical": 3,
78
+ "metric_delta": {"vehicle_precision": -0.004},
79
+ "significance": {
80
+ "method": "seqeval.PairedSequentialTest",
81
+ "significant": false,
82
+ "detail": "precision delta CS includes 0 at alpha=0.05 (from run artifact)"
83
+ }
84
+ }
85
+ }
sensorflow/retro/fixtures/missed_pedestrian_rain.json ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "evaluation_id": "EVAL-2026-0802-MP01",
3
+ "artifact_kind": "perception_failure_eval_log",
4
+ "scenario": {
5
+ "scenario_id": "SCN-URB-042-R",
6
+ "description": "Urban arterial, moderate rain at night. Pedestrian in dark clothing crosses mid-block. Detection is 1.9 s late; the pedestrian is inside ego stopping distance when finally tracked; planner executes a delayed hard brake; near-miss with safety-driver disengagement.",
7
+ "road_type": "urban_arterial",
8
+ "weather": "rain",
9
+ "rain_rate_mmh": 18.0,
10
+ "time_of_day": "night",
11
+ "odd_status": "inside",
12
+ "posted_speed_mps": 15.6
13
+ },
14
+ "models": {
15
+ "baseline": "percept-bev-v41.2",
16
+ "candidate": "percept-bev-v42.0"
17
+ },
18
+ "sensor_metadata": {
19
+ "camera": {"status": "degraded_contrast", "exposure": "night_mode", "fps": 30,
20
+ "note": "wet-road glare and low pedestrian contrast"},
21
+ "lidar": {"status": "attenuated", "returns_density": "reduced_by_spray", "fps": 10},
22
+ "radar": {"status": "nominal", "note": "weak return, pedestrian RCS at range"}
23
+ },
24
+ "ego": {
25
+ "speed_mps": 13.4,
26
+ "heading_deg": 0.0,
27
+ "grade": -0.02,
28
+ "road_friction_estimate": 0.5,
29
+ "lane": 1
30
+ },
31
+ "ground_truth": {
32
+ "object_id": "gt-7742",
33
+ "class": "pedestrian",
34
+ "distance_m": 26.0,
35
+ "lateral_offset_m": 1.1,
36
+ "relative_velocity_mps": 13.9,
37
+ "dimensions_m": [0.6, 0.6, 1.7],
38
+ "crossing": true,
39
+ "source": "human-verified replay label"
40
+ },
41
+ "prediction": {
42
+ "object_id": "det-91027",
43
+ "class": "pedestrian",
44
+ "confidence": 0.34,
45
+ "distance_m": 14.8,
46
+ "relative_velocity_mps": 13.1,
47
+ "track_id": "trk-551",
48
+ "class_history": ["unknown", "unknown", "pedestrian"],
49
+ "detection_delay_s": 1.9,
50
+ "note": "fusion confidence below tracking threshold for 6 consecutive frames before track confirmation"
51
+ },
52
+ "tracks": [
53
+ {"track_id": "trk-551", "frames": 4, "class_flicker": true,
54
+ "velocity_variance": "moderate", "id_switches": 0,
55
+ "confirmation_delay_frames": 6}
56
+ ],
57
+ "timing": {
58
+ "detection_latency_ms": 180,
59
+ "planner_latency_ms": 100
60
+ },
61
+ "planner_response": {
62
+ "action": "emergency_brake",
63
+ "decel_mps2": 8.0,
64
+ "response_time_s": 2.4,
65
+ "lateral_action": "partial_swerve_left"
66
+ },
67
+ "counterfactual_planner_response": {
68
+ "action": "controlled_brake",
69
+ "decel_mps2": 3.5,
70
+ "response_time_s": 0.5,
71
+ "source": "resim replay with on-time pedestrian detection"
72
+ },
73
+ "traffic_context": {
74
+ "following_vehicle": {"present": false, "time_gap_s": null, "speed_mps": null}
75
+ },
76
+ "disengagement": {
77
+ "occurred": true,
78
+ "probability_model": 0.87,
79
+ "note": "safety driver braked simultaneously with the late system response"
80
+ },
81
+ "collision": {"occurred": false, "near_miss": true, "min_gap_m": 1.6},
82
+ "evaluation_context": {
83
+ "critical_object_count": 1240,
84
+ "baseline_missed_critical": 3,
85
+ "candidate_missed_critical": 6,
86
+ "metric_delta": {"recall_pedestrian_rain": -0.021, "recall_pedestrian_overall": -0.006},
87
+ "significance": {
88
+ "method": "seqeval.PairedSequentialTest",
89
+ "significant": true,
90
+ "detail": "pedestrian-rain recall regression CS excludes 0 at alpha=0.05 (from run artifact)"
91
+ },
92
+ "distribution_shift": {
93
+ "slice": "pedestrian_rain_night",
94
+ "candidate_fn_rate": 0.048,
95
+ "baseline_fn_rate": 0.021,
96
+ "relative_change": 1.29
97
+ }
98
+ }
99
+ }
sensorflow/retro/fixtures/missing_telemetry_variant.json ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "evaluation_id": "EVAL-2026-0802-MT01",
3
+ "artifact_kind": "perception_failure_eval_log",
4
+ "scenario": {
5
+ "scenario_id": "SCN-URB-042-R-degraded",
6
+ "description": "Same missed-pedestrian-in-rain event as EVAL-2026-0802-MP01, but the telemetry pipeline dropped the CAN segment: ego speed, relative velocity, and the resim counterfactual are absent. The analyzer must mark the derived safety metrics UNKNOWN, never guess them.",
7
+ "road_type": "urban_arterial",
8
+ "weather": "rain",
9
+ "rain_rate_mmh": 18.0,
10
+ "time_of_day": "night",
11
+ "odd_status": "inside",
12
+ "posted_speed_mps": 15.6
13
+ },
14
+ "models": {
15
+ "baseline": "percept-bev-v41.2",
16
+ "candidate": "percept-bev-v42.0"
17
+ },
18
+ "sensor_metadata": {
19
+ "camera": {"status": "degraded_contrast", "exposure": "night_mode", "fps": 30},
20
+ "lidar": {"status": "attenuated", "returns_density": "reduced_by_spray", "fps": 10},
21
+ "radar": {"status": "telemetry_dropout"}
22
+ },
23
+ "ego": {
24
+ "heading_deg": 0.0,
25
+ "grade": -0.02,
26
+ "road_friction_estimate": 0.5,
27
+ "lane": 1,
28
+ "telemetry_note": "speed_mps absent: CAN log segment lost in upload"
29
+ },
30
+ "ground_truth": {
31
+ "object_id": "gt-7742",
32
+ "class": "pedestrian",
33
+ "distance_m": 26.0,
34
+ "lateral_offset_m": 1.1,
35
+ "dimensions_m": [0.6, 0.6, 1.7],
36
+ "crossing": true,
37
+ "source": "human-verified replay label"
38
+ },
39
+ "prediction": {
40
+ "object_id": "det-91027",
41
+ "class": "pedestrian",
42
+ "confidence": 0.34,
43
+ "distance_m": 14.8,
44
+ "track_id": "trk-551",
45
+ "class_history": ["unknown", "unknown", "pedestrian"],
46
+ "detection_delay_s": 1.9
47
+ },
48
+ "tracks": [
49
+ {"track_id": "trk-551", "frames": 4, "class_flicker": true,
50
+ "velocity_variance": "moderate", "id_switches": 0}
51
+ ],
52
+ "timing": {
53
+ "detection_latency_ms": 180,
54
+ "planner_latency_ms": 100
55
+ },
56
+ "planner_response": {
57
+ "action": "emergency_brake",
58
+ "decel_mps2": 8.0,
59
+ "response_time_s": 2.4
60
+ },
61
+ "traffic_context": {},
62
+ "disengagement": {
63
+ "occurred": true,
64
+ "probability_model": 0.87
65
+ },
66
+ "collision": {"occurred": false, "near_miss": true},
67
+ "evaluation_context": {
68
+ "metric_delta": {"recall_pedestrian_rain": -0.021}
69
+ }
70
+ }
sensorflow/retro/fixtures/noncritical_fn_parked_vehicle.json ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "evaluation_id": "EVAL-2026-0806-NF01",
3
+ "artifact_kind": "perception_failure_eval_log",
4
+ "scenario": {
5
+ "scenario_id": "SCN-RES-330-B",
6
+ "description": "Residential street, low speed. A parked vehicle 87 m ahead on the shoulder is missed for several frames. Ego is slow, the object is static and far outside stopping distance, and no maneuver was required. Benign, non-critical false negative.",
7
+ "road_type": "residential",
8
+ "weather": "clear",
9
+ "rain_rate_mmh": 0.0,
10
+ "time_of_day": "day",
11
+ "odd_status": "inside",
12
+ "posted_speed_mps": 11.2
13
+ },
14
+ "models": {
15
+ "baseline": "percept-bev-v41.2",
16
+ "candidate": "percept-bev-v42.0"
17
+ },
18
+ "sensor_metadata": {
19
+ "camera": {"status": "nominal", "fps": 30},
20
+ "lidar": {"status": "nominal", "returns_density": "sparse_at_range", "fps": 10},
21
+ "radar": {"status": "nominal"}
22
+ },
23
+ "ego": {
24
+ "speed_mps": 8.3,
25
+ "heading_deg": 0.0,
26
+ "grade": 0.0,
27
+ "road_friction_estimate": 0.75,
28
+ "lane": 1
29
+ },
30
+ "ground_truth": {
31
+ "object_id": "gt-5521",
32
+ "class": "vehicle",
33
+ "distance_m": 87.0,
34
+ "lateral_offset_m": 2.8,
35
+ "relative_velocity_mps": -0.2,
36
+ "dimensions_m": [4.4, 1.8, 1.5],
37
+ "parked": true,
38
+ "source": "human-verified replay label"
39
+ },
40
+ "prediction": null,
41
+ "tracks": [],
42
+ "timing": {
43
+ "detection_latency_ms": 130,
44
+ "planner_latency_ms": 75
45
+ },
46
+ "planner_response": {
47
+ "action": "maintain_speed",
48
+ "decel_mps2": 0.0,
49
+ "response_time_s": 0.0
50
+ },
51
+ "counterfactual_planner_response": {
52
+ "action": "maintain_speed",
53
+ "decel_mps2": 0.0,
54
+ "response_time_s": 0.0,
55
+ "source": "resim replay with the parked vehicle detected: identical plan"
56
+ },
57
+ "traffic_context": {
58
+ "following_vehicle": {"present": false, "time_gap_s": null, "speed_mps": null}
59
+ },
60
+ "disengagement": {"occurred": false, "probability_model": 0.01},
61
+ "collision": {"occurred": false, "near_miss": false},
62
+ "evaluation_context": {
63
+ "critical_object_count": 1240,
64
+ "baseline_missed_critical": 3,
65
+ "candidate_missed_critical": 3,
66
+ "metric_delta": {"vehicle_recall_static_far": -0.008},
67
+ "significance": {
68
+ "method": "seqeval.PairedSequentialTest",
69
+ "significant": false,
70
+ "detail": "far-static recall delta CS includes 0 at alpha=0.05 (from run artifact)"
71
+ }
72
+ }
73
+ }
sensorflow/retro/fixtures/phantom_brake_plastic_bag.json ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "evaluation_id": "EVAL-2026-0731-PB01",
3
+ "artifact_kind": "perception_failure_eval_log",
4
+ "scenario": {
5
+ "scenario_id": "SCN-HWY-118-C",
6
+ "description": "Divided highway, light traffic. Wind-blown plastic bag crosses lanes ahead of ego; perception classifies it as a vehicle with spurious closing velocity; planner executes an unwarranted emergency brake with a close follower behind.",
7
+ "road_type": "divided_highway",
8
+ "weather": "clear",
9
+ "rain_rate_mmh": 0.0,
10
+ "time_of_day": "day",
11
+ "odd_status": "inside",
12
+ "posted_speed_mps": 29.1
13
+ },
14
+ "models": {
15
+ "baseline": "percept-bev-v41.2",
16
+ "candidate": "percept-bev-v42.0"
17
+ },
18
+ "sensor_metadata": {
19
+ "camera": {"status": "nominal", "exposure": "auto", "fps": 30},
20
+ "lidar": {"status": "nominal", "returns_density": "normal", "fps": 10},
21
+ "radar": {"status": "nominal", "note": "low RCS return consistent with low-mass object"}
22
+ },
23
+ "ego": {
24
+ "speed_mps": 24.6,
25
+ "heading_deg": 0.0,
26
+ "grade": 0.0,
27
+ "road_friction_estimate": 0.85,
28
+ "lane": 2
29
+ },
30
+ "ground_truth": {
31
+ "object_id": "gt-3312",
32
+ "class": "plastic_bag",
33
+ "distance_m": 38.2,
34
+ "lateral_offset_m": 0.4,
35
+ "relative_velocity_mps": 1.2,
36
+ "dimensions_m": [0.5, 0.4, 0.3],
37
+ "mass_plausibility": "low_mass_deformable",
38
+ "source": "human-verified replay label"
39
+ },
40
+ "prediction": {
41
+ "object_id": "det-88410",
42
+ "class": "vehicle",
43
+ "confidence": 0.91,
44
+ "distance_m": 37.9,
45
+ "relative_velocity_mps": 9.8,
46
+ "track_id": "trk-204",
47
+ "class_history": ["unknown", "vehicle", "vehicle", "vehicle"],
48
+ "detection_delay_s": 0.0,
49
+ "velocity_note": "tracker latched bag flutter as closing velocity toward ego"
50
+ },
51
+ "tracks": [
52
+ {"track_id": "trk-204", "frames": 11, "class_flicker": true,
53
+ "velocity_variance": "high", "id_switches": 0}
54
+ ],
55
+ "timing": {
56
+ "detection_latency_ms": 120,
57
+ "planner_latency_ms": 80
58
+ },
59
+ "planner_response": {
60
+ "action": "emergency_brake",
61
+ "decel_mps2": 6.2,
62
+ "response_time_s": 0.4,
63
+ "lateral_action": null
64
+ },
65
+ "counterfactual_planner_response": {
66
+ "action": "maintain_speed",
67
+ "decel_mps2": 0.0,
68
+ "response_time_s": 0.4,
69
+ "source": "resim replay with corrected perception (bag labeled low-mass debris)"
70
+ },
71
+ "traffic_context": {
72
+ "following_vehicle": {"present": true, "time_gap_s": 0.9, "speed_mps": 25.1}
73
+ },
74
+ "disengagement": {
75
+ "occurred": false,
76
+ "probability_model": 0.41,
77
+ "note": "safety driver hands hovered; no takeover"
78
+ },
79
+ "collision": {"occurred": false, "near_miss": false, "induced_rear_risk": true},
80
+ "evaluation_context": {
81
+ "critical_object_count": 1240,
82
+ "baseline_missed_critical": 3,
83
+ "candidate_missed_critical": 3,
84
+ "metric_delta": {"phantom_brake_rate_per_1k_km": 0.8, "vehicle_precision": -0.011},
85
+ "significance": {
86
+ "method": "seqeval.PairedSequentialTest",
87
+ "significant": false,
88
+ "detail": "phantom-brake rate delta CS still includes 0 at alpha=0.05 (from run artifact)"
89
+ },
90
+ "distribution_shift": {
91
+ "slice": "highway_debris_windblown",
92
+ "candidate_fp_rate": 0.034,
93
+ "baseline_fp_rate": 0.012,
94
+ "relative_change": 1.83
95
+ }
96
+ }
97
+ }
sensorflow/retro/inference/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Inference foundation: environment detection, vLLM compatibility chain,
2
+ and the pluggable backend client (vllm | ollama | mock)."""
sensorflow/retro/inference/client.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pluggable inference client: vllm | ollama | mock backends.
2
+
3
+ All backends implement the same contract:
4
+ health() -> BackendStatus (never raises)
5
+ generate(prompt, system, max_tokens) -> InferenceResult with token counts
6
+ and wall-clock latency
7
+ validate() -> deterministic test-prompt round trip
8
+
9
+ The mock backend is the default for tests: fully deterministic, no network.
10
+ The ollama backend follows the platform's existing local pattern
11
+ (sensorflow/evaluation/copilot.py). The vllm backend targets any
12
+ OpenAI-compatible endpoint and is only exercisable on CUDA/ROCm hosts —
13
+ see sensorflow/retro/inference/vllm_server/.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import os
20
+ import time
21
+ from abc import ABC, abstractmethod
22
+ from typing import List, Optional
23
+
24
+ import httpx
25
+ from pydantic import BaseModel, Field
26
+
27
+ VALIDATION_PROMPT = "Respond with exactly the token RETRO-OK and nothing else."
28
+ VALIDATION_EXPECT = "RETRO-OK"
29
+
30
+ DEFAULT_OLLAMA_URL = os.environ.get("RETRO_OLLAMA_URL", "http://localhost:11434")
31
+ DEFAULT_VLLM_URL = os.environ.get("RETRO_VLLM_URL", "http://localhost:8001/v1")
32
+
33
+
34
+ class InferenceResult(BaseModel):
35
+ backend: str
36
+ model: str
37
+ text: str
38
+ prompt_tokens: int
39
+ completion_tokens: int
40
+ latency_ms: float
41
+ notes: str = ""
42
+
43
+
44
+ class BackendStatus(BaseModel):
45
+ backend: str
46
+ available: bool
47
+ endpoint: Optional[str] = None
48
+ model: Optional[str] = None
49
+ detail: str = ""
50
+ health_latency_ms: Optional[float] = None
51
+
52
+
53
+ class ValidationResult(BaseModel):
54
+ backend: str
55
+ passed: bool
56
+ expected: str
57
+ got: str
58
+ latency_ms: float
59
+ prompt_tokens: int
60
+ completion_tokens: int
61
+ detail: str = ""
62
+
63
+
64
+ class InferenceBackend(ABC):
65
+ name: str = "abstract"
66
+
67
+ @abstractmethod
68
+ def health(self) -> BackendStatus: ...
69
+
70
+ @abstractmethod
71
+ def generate(self, prompt: str, system: Optional[str] = None,
72
+ max_tokens: int = 1024) -> InferenceResult: ...
73
+
74
+ def validate(self) -> ValidationResult:
75
+ """Deterministic test-prompt inference validation with latency."""
76
+ t0 = time.perf_counter()
77
+ try:
78
+ res = self.generate(VALIDATION_PROMPT, max_tokens=16)
79
+ except Exception as exc:
80
+ return ValidationResult(backend=self.name, passed=False,
81
+ expected=VALIDATION_EXPECT, got="",
82
+ latency_ms=(time.perf_counter() - t0) * 1000,
83
+ prompt_tokens=0, completion_tokens=0,
84
+ detail=f"generate failed: {exc}")
85
+ passed = VALIDATION_EXPECT in res.text
86
+ return ValidationResult(backend=self.name, passed=passed,
87
+ expected=VALIDATION_EXPECT, got=res.text.strip()[:200],
88
+ latency_ms=res.latency_ms,
89
+ prompt_tokens=res.prompt_tokens,
90
+ completion_tokens=res.completion_tokens,
91
+ detail="" if passed else
92
+ "model reply did not contain the expected token")
93
+
94
+
95
+ def _approx_tokens(text: str) -> int:
96
+ """Deterministic whitespace-ish token approximation (mock backend only)."""
97
+ return max(1, len(text.split())) if text else 0
98
+
99
+
100
+ class MockBackend(InferenceBackend):
101
+ """Deterministic, offline. Replies are pure functions of the prompt.
102
+
103
+ The retrospective agent does NOT parse mock replies for analysis content —
104
+ in mock mode the analysis itself is produced by the deterministic scripted
105
+ path (sensorflow/retro/agent). The mock backend exists to satisfy the
106
+ client contract (health/generate/validate) without any model.
107
+ """
108
+
109
+ name = "mock"
110
+
111
+ def health(self) -> BackendStatus:
112
+ return BackendStatus(backend="mock", available=True, endpoint=None,
113
+ model="deterministic-mock",
114
+ detail="always available; replies are deterministic "
115
+ "functions of the prompt", health_latency_ms=0.0)
116
+
117
+ def generate(self, prompt: str, system: Optional[str] = None,
118
+ max_tokens: int = 1024) -> InferenceResult:
119
+ t0 = time.perf_counter()
120
+ if VALIDATION_EXPECT in prompt or "RETRO-OK" in prompt:
121
+ text = VALIDATION_EXPECT
122
+ else:
123
+ digest = hashlib.sha256(((system or "") + "\n" + prompt).encode()).hexdigest()[:12]
124
+ text = f"[mock-response {digest}] Deterministic placeholder reply."
125
+ return InferenceResult(
126
+ backend="mock", model="deterministic-mock", text=text,
127
+ prompt_tokens=_approx_tokens((system or "") + " " + prompt),
128
+ completion_tokens=_approx_tokens(text),
129
+ latency_ms=(time.perf_counter() - t0) * 1000,
130
+ notes="deterministic mock; token counts are whitespace approximations")
131
+
132
+
133
+ class OllamaBackend(InferenceBackend):
134
+ """Local Ollama server (Metal/CPU on this machine — NOT vLLM numbers)."""
135
+
136
+ name = "ollama"
137
+
138
+ def __init__(self, base_url: str = DEFAULT_OLLAMA_URL,
139
+ model: Optional[str] = None, timeout: float = 120.0):
140
+ self.base_url = base_url.rstrip("/")
141
+ self.model = model or os.environ.get("RETRO_OLLAMA_MODEL", "")
142
+ self.timeout = timeout
143
+
144
+ def _pick_model(self) -> Optional[str]:
145
+ if self.model:
146
+ return self.model
147
+ try:
148
+ res = httpx.get(f"{self.base_url}/api/tags", timeout=3.0)
149
+ models = [m.get("name") for m in res.json().get("models", [])]
150
+ self.model = models[0] if models else None
151
+ except Exception:
152
+ self.model = None
153
+ return self.model
154
+
155
+ def health(self) -> BackendStatus:
156
+ t0 = time.perf_counter()
157
+ try:
158
+ res = httpx.get(f"{self.base_url}/api/tags", timeout=3.0)
159
+ ms = (time.perf_counter() - t0) * 1000
160
+ if res.status_code == 200:
161
+ models = [m.get("name") for m in res.json().get("models", [])]
162
+ return BackendStatus(backend="ollama", available=True,
163
+ endpoint=self.base_url,
164
+ model=self.model or (models[0] if models else None),
165
+ detail=f"{len(models)} model(s) installed",
166
+ health_latency_ms=round(ms, 1))
167
+ return BackendStatus(backend="ollama", available=False,
168
+ endpoint=self.base_url,
169
+ detail=f"HTTP {res.status_code} from /api/tags")
170
+ except Exception as exc:
171
+ return BackendStatus(backend="ollama", available=False,
172
+ endpoint=self.base_url,
173
+ detail=f"unreachable: {exc.__class__.__name__}: {exc}")
174
+
175
+ def generate(self, prompt: str, system: Optional[str] = None,
176
+ max_tokens: int = 1024) -> InferenceResult:
177
+ model = self._pick_model()
178
+ if not model:
179
+ raise RuntimeError(f"no Ollama model available at {self.base_url}")
180
+ messages = ([{"role": "system", "content": system}] if system else []) + [
181
+ {"role": "user", "content": prompt}]
182
+ t0 = time.perf_counter()
183
+ res = httpx.post(f"{self.base_url}/api/chat",
184
+ json={"model": model, "messages": messages, "stream": False,
185
+ "options": {"num_predict": max_tokens}},
186
+ timeout=self.timeout)
187
+ latency_ms = (time.perf_counter() - t0) * 1000
188
+ res.raise_for_status()
189
+ body = res.json()
190
+ return InferenceResult(
191
+ backend="ollama", model=model,
192
+ text=body.get("message", {}).get("content", ""),
193
+ prompt_tokens=int(body.get("prompt_eval_count", 0)),
194
+ completion_tokens=int(body.get("eval_count", 0)),
195
+ latency_ms=round(latency_ms, 1),
196
+ notes="Ollama on local CPU/Metal — these are NOT vLLM/GPU numbers")
197
+
198
+
199
+ class VLLMBackend(InferenceBackend):
200
+ """OpenAI-compatible vLLM endpoint. Only runnable against a CUDA/ROCm host
201
+ (see vllm_server/); on this macOS machine health() honestly reports it
202
+ unavailable unless a remote endpoint is configured."""
203
+
204
+ name = "vllm"
205
+
206
+ def __init__(self, base_url: str = DEFAULT_VLLM_URL,
207
+ model: Optional[str] = None, timeout: float = 120.0):
208
+ self.base_url = base_url.rstrip("/")
209
+ self.model = model or os.environ.get("RETRO_VLLM_MODEL", "")
210
+ self.timeout = timeout
211
+
212
+ def health(self) -> BackendStatus:
213
+ t0 = time.perf_counter()
214
+ try:
215
+ res = httpx.get(f"{self.base_url}/models", timeout=3.0)
216
+ ms = (time.perf_counter() - t0) * 1000
217
+ if res.status_code == 200:
218
+ models = [m.get("id") for m in res.json().get("data", [])]
219
+ return BackendStatus(backend="vllm", available=True,
220
+ endpoint=self.base_url,
221
+ model=self.model or (models[0] if models else None),
222
+ detail=f"OpenAI-compatible server, models: {models}",
223
+ health_latency_ms=round(ms, 1))
224
+ return BackendStatus(backend="vllm", available=False,
225
+ endpoint=self.base_url,
226
+ detail=f"HTTP {res.status_code} from /models")
227
+ except Exception as exc:
228
+ return BackendStatus(backend="vllm", available=False,
229
+ endpoint=self.base_url,
230
+ detail=f"unreachable ({exc.__class__.__name__}); vLLM "
231
+ "cannot run on this macOS machine — point "
232
+ "RETRO_VLLM_URL at a CUDA/ROCm host")
233
+
234
+ def generate(self, prompt: str, system: Optional[str] = None,
235
+ max_tokens: int = 1024) -> InferenceResult:
236
+ model = self.model
237
+ if not model:
238
+ res = httpx.get(f"{self.base_url}/models", timeout=5.0)
239
+ data = res.json().get("data", [])
240
+ if not data:
241
+ raise RuntimeError(f"no models served at {self.base_url}")
242
+ model = data[0]["id"]
243
+ messages = ([{"role": "system", "content": system}] if system else []) + [
244
+ {"role": "user", "content": prompt}]
245
+ t0 = time.perf_counter()
246
+ res = httpx.post(f"{self.base_url}/chat/completions",
247
+ json={"model": model, "messages": messages,
248
+ "max_tokens": max_tokens, "temperature": 0.0},
249
+ timeout=self.timeout)
250
+ latency_ms = (time.perf_counter() - t0) * 1000
251
+ res.raise_for_status()
252
+ body = res.json()
253
+ usage = body.get("usage", {})
254
+ return InferenceResult(
255
+ backend="vllm", model=model,
256
+ text=body["choices"][0]["message"]["content"],
257
+ prompt_tokens=int(usage.get("prompt_tokens", 0)),
258
+ completion_tokens=int(usage.get("completion_tokens", 0)),
259
+ latency_ms=round(latency_ms, 1))
260
+
261
+
262
+ BACKENDS = {"mock": MockBackend, "ollama": OllamaBackend, "vllm": VLLMBackend}
263
+
264
+
265
+ def get_backend(name: str = "mock", **kwargs) -> InferenceBackend:
266
+ if name not in BACKENDS:
267
+ raise ValueError(f"unknown backend {name!r}; expected one of {sorted(BACKENDS)}")
268
+ return BACKENDS[name](**kwargs)
269
+
270
+
271
+ def all_backend_statuses() -> List[BackendStatus]:
272
+ return [get_backend(n).health() for n in ("mock", "ollama", "vllm")]
sensorflow/retro/inference/compat.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """vLLM compatibility validation chain.
2
+
3
+ Chain order (each link depends on the previous):
4
+ GPU -> driver -> torch -> vLLM -> model -> quantization
5
+
6
+ Every link produces a CompatCheck with PASS / FAIL / SKIPPED (blocked by an
7
+ earlier failure) plus a human-readable reason and remediation. On this
8
+ project's development machine (macOS, no CUDA/ROCm) the chain fails honestly
9
+ at the GPU link and reports vLLM as unsupported.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import List, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from sensorflow.retro.inference.env_detect import EnvironmentReport, detect_environment
19
+
20
+ PASS = "PASS"
21
+ FAIL = "FAIL"
22
+ SKIPPED = "SKIPPED"
23
+
24
+ # Quantization methods vLLM supports on CUDA; ROCm support is narrower.
25
+ # See docs/retro/compatibility-matrix.md for the substantiated matrix.
26
+ CUDA_QUANTIZATIONS = {"awq", "gptq", "fp8", "int8", "none"}
27
+ ROCM_QUANTIZATIONS = {"fp8", "gptq", "none"} # verify against current vLLM release
28
+
29
+ # Very rough VRAM floor (GB) per model family for a usable context window.
30
+ MODEL_VRAM_FLOOR_GB = {
31
+ "7b": 16, "8b": 18, "13b": 28, "14b": 30, "32b": 68, "70b": 140,
32
+ }
33
+
34
+
35
+ class CompatCheck(BaseModel):
36
+ link: str # gpu | driver | torch | vllm | model | quantization
37
+ status: str # PASS | FAIL | SKIPPED
38
+ reason: str
39
+ remediation: Optional[str] = None
40
+
41
+
42
+ class CompatReport(BaseModel):
43
+ vllm_supported: bool
44
+ platform_summary: str
45
+ checks: List[CompatCheck]
46
+ failed_link: Optional[str] = None
47
+ notes: List[str] = Field(default_factory=list)
48
+
49
+
50
+ def _estimate_vram_floor(model_name: str) -> Optional[int]:
51
+ lname = model_name.lower()
52
+ for size, gb in MODEL_VRAM_FLOOR_GB.items():
53
+ if size in lname:
54
+ return gb
55
+ return None
56
+
57
+
58
+ def check_vllm_compatibility(env: Optional[EnvironmentReport] = None,
59
+ model_name: str = "meta-llama/Llama-3.1-8B-Instruct",
60
+ quantization: str = "none") -> CompatReport:
61
+ """Run the full GPU->driver->torch->vLLM->model->quantization chain."""
62
+ env = env or detect_environment(probe_ollama=False)
63
+ checks: List[CompatCheck] = []
64
+ failed: Optional[str] = None
65
+
66
+ def blocked(link: str, by: str) -> None:
67
+ checks.append(CompatCheck(
68
+ link=link, status=SKIPPED,
69
+ reason=f"not evaluated: blocked by failed '{by}' check"))
70
+
71
+ # --- 1. GPU -------------------------------------------------------------
72
+ cuda_gpus = [g for g in env.gpus if g.vendor == "nvidia"]
73
+ rocm_gpus = [g for g in env.gpus if g.vendor == "amd"]
74
+ gpu_vendor = "nvidia" if cuda_gpus else ("amd" if rocm_gpus else None)
75
+
76
+ if gpu_vendor:
77
+ gpu = (cuda_gpus or rocm_gpus)[0]
78
+ checks.append(CompatCheck(
79
+ link="gpu", status=PASS,
80
+ reason=f"{gpu.vendor.upper()} GPU detected: {gpu.model}"
81
+ + (f" ({gpu.memory_mb} MB VRAM)" if gpu.memory_mb else "")))
82
+ else:
83
+ failed = "gpu"
84
+ apple = next((g for g in env.gpus if g.vendor == "apple"), None)
85
+ if env.is_macos:
86
+ reason = (f"No CUDA or ROCm capable GPU. This is macOS "
87
+ f"({env.machine_arch})"
88
+ + (f" with an Apple GPU ({apple.model}), which exposes "
89
+ f"Metal/MPS only" if apple else "")
90
+ + ". vLLM requires NVIDIA CUDA or AMD ROCm and does not "
91
+ "run on macOS.")
92
+ remediation = ("Run the vLLM server on a Linux host with an NVIDIA "
93
+ "or AMD data-center/consumer GPU; use the 'ollama' "
94
+ "backend for local inference on this machine.")
95
+ else:
96
+ reason = "No NVIDIA or AMD GPU detected (nvidia-smi / rocm-smi absent or empty)."
97
+ remediation = "Install GPU drivers or move to a GPU host."
98
+ checks.append(CompatCheck(link="gpu", status=FAIL, reason=reason,
99
+ remediation=remediation))
100
+
101
+ # --- 2. Driver ----------------------------------------------------------
102
+ if failed:
103
+ blocked("driver", failed)
104
+ elif gpu_vendor == "nvidia":
105
+ drv = cuda_gpus[0].driver_version
106
+ if drv:
107
+ checks.append(CompatCheck(link="driver", status=PASS,
108
+ reason=f"NVIDIA driver {drv} reported by nvidia-smi"))
109
+ else:
110
+ failed = "driver"
111
+ checks.append(CompatCheck(
112
+ link="driver", status=FAIL,
113
+ reason="nvidia-smi present but did not report a driver version",
114
+ remediation="Reinstall the NVIDIA driver (>=535 recommended for "
115
+ "current vLLM/CUDA 12 wheels)."))
116
+ else: # amd
117
+ if env.rocm_smi_present:
118
+ checks.append(CompatCheck(link="driver", status=PASS,
119
+ reason="ROCm stack present (rocm-smi responds)"))
120
+ else:
121
+ failed = "driver"
122
+ checks.append(CompatCheck(link="driver", status=FAIL,
123
+ reason="AMD GPU without a working ROCm install",
124
+ remediation="Install ROCm >= 6.x for the target GPU."))
125
+
126
+ # --- 3. torch -----------------------------------------------------------
127
+ if failed:
128
+ blocked("torch", failed)
129
+ elif not env.torch.installed:
130
+ failed = "torch"
131
+ checks.append(CompatCheck(
132
+ link="torch", status=FAIL,
133
+ reason=f"PyTorch is not installed in this environment ({env.torch.detail})",
134
+ remediation="pip install a torch build matching the GPU stack "
135
+ "(cu12x wheels for CUDA; rocm wheels for AMD)."))
136
+ elif gpu_vendor == "nvidia" and not env.torch.cuda_available:
137
+ failed = "torch"
138
+ checks.append(CompatCheck(
139
+ link="torch", status=FAIL,
140
+ reason=f"torch {env.torch.version} installed but torch.cuda.is_available() "
141
+ "is False (CPU-only build or driver mismatch)",
142
+ remediation="Install a CUDA-enabled torch wheel matching the driver."))
143
+ elif gpu_vendor == "amd" and not env.torch.rocm_version:
144
+ failed = "torch"
145
+ checks.append(CompatCheck(
146
+ link="torch", status=FAIL,
147
+ reason=f"torch {env.torch.version} is not a ROCm build",
148
+ remediation="Install the ROCm torch wheel (torch.version.hip must be set)."))
149
+ else:
150
+ checks.append(CompatCheck(
151
+ link="torch", status=PASS,
152
+ reason=f"torch {env.torch.version} with a matching accelerator build"))
153
+
154
+ # --- 4. vLLM ------------------------------------------------------------
155
+ if failed:
156
+ blocked("vllm", failed)
157
+ elif not env.vllm_installed:
158
+ failed = "vllm"
159
+ checks.append(CompatCheck(
160
+ link="vllm", status=FAIL,
161
+ reason="vllm package is not installed",
162
+ remediation="pip install vllm (CUDA) or build the ROCm image; see "
163
+ "docs/retro/compatibility-matrix.md."))
164
+ else:
165
+ checks.append(CompatCheck(link="vllm", status=PASS,
166
+ reason=f"vllm {env.vllm_version} importable"))
167
+
168
+ # --- 5. Model fits ------------------------------------------------------
169
+ if failed:
170
+ blocked("model", failed)
171
+ else:
172
+ floor = _estimate_vram_floor(model_name)
173
+ vram_mb = (cuda_gpus or rocm_gpus)[0].memory_mb
174
+ if floor is None or vram_mb is None:
175
+ checks.append(CompatCheck(
176
+ link="model", status=PASS,
177
+ reason=f"cannot pre-validate VRAM for '{model_name}' "
178
+ "(unknown size class or unreported VRAM); vLLM will "
179
+ "validate at load time"))
180
+ elif vram_mb / 1024 < floor:
181
+ failed = "model"
182
+ checks.append(CompatCheck(
183
+ link="model", status=FAIL,
184
+ reason=f"'{model_name}' needs roughly >={floor} GB VRAM unquantized; "
185
+ f"GPU reports {vram_mb / 1024:.0f} GB",
186
+ remediation="Use quantization (AWQ/GPTQ/FP8), a smaller model, or "
187
+ "tensor parallelism across more GPUs."))
188
+ else:
189
+ checks.append(CompatCheck(
190
+ link="model", status=PASS,
191
+ reason=f"'{model_name}' plausibly fits in {vram_mb / 1024:.0f} GB VRAM"))
192
+
193
+ # --- 6. Quantization ----------------------------------------------------
194
+ q = (quantization or "none").lower()
195
+ if failed:
196
+ blocked("quantization", failed)
197
+ else:
198
+ supported = CUDA_QUANTIZATIONS if gpu_vendor == "nvidia" else ROCM_QUANTIZATIONS
199
+ if q in supported:
200
+ checks.append(CompatCheck(
201
+ link="quantization", status=PASS,
202
+ reason=f"quantization '{q}' is supported on {gpu_vendor} "
203
+ "(verify against the installed vLLM release)"))
204
+ else:
205
+ failed = "quantization"
206
+ checks.append(CompatCheck(
207
+ link="quantization", status=FAIL,
208
+ reason=f"quantization '{q}' is not supported on {gpu_vendor} "
209
+ f"(supported: {sorted(supported)})",
210
+ remediation="Pick a supported method or run unquantized."))
211
+
212
+ if env.is_macos:
213
+ summary = (f"macOS {env.os_version} ({env.machine_arch}): vLLM UNSUPPORTED "
214
+ "on this machine. Use the ollama or mock backend locally; run "
215
+ "vLLM on a CUDA/ROCm Linux host.")
216
+ elif failed:
217
+ summary = f"vLLM unsupported here: chain failed at '{failed}'."
218
+ else:
219
+ summary = "All compatibility checks passed; vLLM should run on this host."
220
+
221
+ return CompatReport(
222
+ vllm_supported=failed is None,
223
+ platform_summary=summary,
224
+ checks=checks,
225
+ failed_link=failed,
226
+ notes=list(env.notes),
227
+ )
228
+
229
+
230
+ def format_report(report: CompatReport) -> str:
231
+ lines = [report.platform_summary, ""]
232
+ for c in report.checks:
233
+ mark = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIPPED": "[SKIP]"}[c.status]
234
+ lines.append(f"{mark} {c.link:<13} {c.reason}")
235
+ if c.remediation:
236
+ lines.append(f" remediation: {c.remediation}")
237
+ return "\n".join(lines)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ print(format_report(check_vllm_compatibility()))
sensorflow/retro/inference/env_detect.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real hardware/software environment detection for the inference layer.
2
+
3
+ Every field is probed from the actual machine (platform module, subprocess
4
+ calls to nvidia-smi / rocm-smi / system_profiler, and import probes for
5
+ torch / vllm). Nothing is assumed or fabricated: absent components are
6
+ reported as absent, and probe failures carry the failure detail.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from typing import List, Optional
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+
21
+ class GPUInfo(BaseModel):
22
+ vendor: str # nvidia | amd | apple | intel | unknown
23
+ model: str
24
+ memory_mb: Optional[int] = None # dedicated VRAM if known; None for unified
25
+ unified_memory: bool = False # Apple Silicon shares system RAM
26
+ driver_version: Optional[str] = None
27
+ compute_capability: Optional[str] = None
28
+ detail: str = ""
29
+
30
+
31
+ class TorchInfo(BaseModel):
32
+ installed: bool
33
+ version: Optional[str] = None
34
+ cuda_available: bool = False
35
+ cuda_version: Optional[str] = None
36
+ rocm_version: Optional[str] = None
37
+ mps_available: bool = False
38
+ detail: str = ""
39
+
40
+
41
+ class EnvironmentReport(BaseModel):
42
+ os_name: str
43
+ os_version: str
44
+ machine_arch: str
45
+ is_macos: bool
46
+ is_apple_silicon: bool
47
+ python_version: str
48
+ torch: TorchInfo
49
+ gpus: List[GPUInfo] = Field(default_factory=list)
50
+ cuda_toolkit_version: Optional[str] = None # from nvcc, if present
51
+ rocm_installed: bool = False
52
+ nvidia_smi_present: bool = False
53
+ rocm_smi_present: bool = False
54
+ vllm_installed: bool = False
55
+ vllm_version: Optional[str] = None
56
+ ollama_endpoint: Optional[str] = None # reachable Ollama server, if any
57
+ ollama_models: List[str] = Field(default_factory=list)
58
+ notes: List[str] = Field(default_factory=list)
59
+
60
+
61
+ def _run(cmd: List[str], timeout: float = 5.0) -> Optional[str]:
62
+ try:
63
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
64
+ if out.returncode == 0:
65
+ return out.stdout
66
+ except (OSError, subprocess.SubprocessError):
67
+ pass
68
+ return None
69
+
70
+
71
+ def _detect_nvidia() -> List[GPUInfo]:
72
+ if not shutil.which("nvidia-smi"):
73
+ return []
74
+ out = _run(["nvidia-smi", "--query-gpu=name,memory.total,driver_version,compute_cap",
75
+ "--format=csv,noheader,nounits"])
76
+ gpus: List[GPUInfo] = []
77
+ for line in (out or "").strip().splitlines():
78
+ parts = [p.strip() for p in line.split(",")]
79
+ if len(parts) >= 2:
80
+ gpus.append(GPUInfo(
81
+ vendor="nvidia", model=parts[0],
82
+ memory_mb=int(float(parts[1])) if parts[1].replace(".", "").isdigit() else None,
83
+ driver_version=parts[2] if len(parts) > 2 else None,
84
+ compute_capability=parts[3] if len(parts) > 3 else None,
85
+ detail="detected via nvidia-smi"))
86
+ return gpus
87
+
88
+
89
+ def _detect_amd() -> List[GPUInfo]:
90
+ if not shutil.which("rocm-smi"):
91
+ return []
92
+ out = _run(["rocm-smi", "--showproductname", "--json"])
93
+ gpus: List[GPUInfo] = []
94
+ if out:
95
+ try:
96
+ data = json.loads(out)
97
+ for card, fields in data.items():
98
+ name = (fields.get("Card series") or fields.get("Card SKU")
99
+ or fields.get("Card model") or card)
100
+ gpus.append(GPUInfo(vendor="amd", model=str(name),
101
+ detail="detected via rocm-smi"))
102
+ except (json.JSONDecodeError, AttributeError):
103
+ gpus.append(GPUInfo(vendor="amd", model="unknown AMD GPU",
104
+ detail="rocm-smi present but output unparseable"))
105
+ return gpus
106
+
107
+
108
+ def _detect_apple() -> List[GPUInfo]:
109
+ if platform.system() != "Darwin":
110
+ return []
111
+ out = _run(["system_profiler", "SPDisplaysDataType", "-json"], timeout=15.0)
112
+ gpus: List[GPUInfo] = []
113
+ if out:
114
+ try:
115
+ data = json.loads(out)
116
+ for item in data.get("SPDisplaysDataType", []):
117
+ model = item.get("sppci_model", "Apple GPU")
118
+ cores = item.get("sppci_cores")
119
+ detail = "detected via system_profiler"
120
+ if cores:
121
+ detail += f"; {cores} GPU cores"
122
+ gpus.append(GPUInfo(vendor="apple", model=model, unified_memory=True,
123
+ detail=detail))
124
+ except json.JSONDecodeError:
125
+ pass
126
+ if not gpus:
127
+ # Apple Silicon always has an integrated GPU even if profiling failed.
128
+ gpus.append(GPUInfo(vendor="apple", model=f"Apple GPU ({platform.machine()})",
129
+ unified_memory=True,
130
+ detail="system_profiler unavailable; inferred from platform"))
131
+ return gpus
132
+
133
+
134
+ def _detect_torch() -> TorchInfo:
135
+ try:
136
+ import torch # type: ignore
137
+ except ImportError as exc:
138
+ return TorchInfo(installed=False, detail=f"torch not importable: {exc}")
139
+ info = TorchInfo(installed=True, version=getattr(torch, "__version__", None))
140
+ try:
141
+ info.cuda_available = bool(torch.cuda.is_available())
142
+ info.cuda_version = getattr(torch.version, "cuda", None)
143
+ info.rocm_version = getattr(torch.version, "hip", None)
144
+ mps = getattr(torch.backends, "mps", None)
145
+ info.mps_available = bool(mps and mps.is_available())
146
+ info.detail = "torch probed successfully"
147
+ except Exception as exc: # torch runtime probing should never crash detection
148
+ info.detail = f"torch installed but probing failed: {exc}"
149
+ return info
150
+
151
+
152
+ def _detect_nvcc() -> Optional[str]:
153
+ out = _run(["nvcc", "--version"]) if shutil.which("nvcc") else None
154
+ if out:
155
+ for line in out.splitlines():
156
+ if "release" in line:
157
+ return line.strip()
158
+ return None
159
+
160
+
161
+ def _detect_ollama(endpoints: Optional[List[str]] = None) -> tuple[Optional[str], List[str]]:
162
+ import httpx
163
+ for base in endpoints or ["http://localhost:11434"]:
164
+ try:
165
+ res = httpx.get(f"{base}/api/tags", timeout=2.0)
166
+ if res.status_code == 200:
167
+ models = [m.get("name", "?") for m in res.json().get("models", [])]
168
+ return base, models
169
+ except Exception:
170
+ continue
171
+ return None, []
172
+
173
+
174
+ def detect_environment(probe_ollama: bool = True) -> EnvironmentReport:
175
+ """Probe the actual machine. Safe to call anywhere; never raises."""
176
+ system = platform.system()
177
+ arch = platform.machine()
178
+ is_macos = system == "Darwin"
179
+ is_apple_silicon = is_macos and arch == "arm64"
180
+
181
+ gpus = _detect_nvidia() + _detect_amd()
182
+ if not gpus and is_macos:
183
+ gpus = _detect_apple()
184
+
185
+ try:
186
+ import vllm # type: ignore
187
+ vllm_installed, vllm_version = True, getattr(vllm, "__version__", "unknown")
188
+ except ImportError:
189
+ vllm_installed, vllm_version = False, None
190
+
191
+ ollama_ep, ollama_models = (None, [])
192
+ if probe_ollama:
193
+ ollama_ep, ollama_models = _detect_ollama()
194
+
195
+ notes: List[str] = []
196
+ if is_macos:
197
+ notes.append("macOS detected: vLLM does not run on macOS (no CUDA, no ROCm); "
198
+ "Apple GPU acceleration is Metal/MPS which vLLM does not target.")
199
+ if is_apple_silicon:
200
+ notes.append("Apple Silicon (arm64) with unified memory; local LLM inference "
201
+ "is possible via Ollama (Metal), not vLLM.")
202
+ if not shutil.which("nvidia-smi") and not shutil.which("rocm-smi"):
203
+ notes.append("Neither nvidia-smi nor rocm-smi found on PATH: no CUDA or "
204
+ "ROCm capable GPU stack is installed on this machine.")
205
+
206
+ return EnvironmentReport(
207
+ os_name=system,
208
+ os_version=platform.mac_ver()[0] if is_macos else platform.release(),
209
+ machine_arch=arch,
210
+ is_macos=is_macos,
211
+ is_apple_silicon=is_apple_silicon,
212
+ python_version=".".join(map(str, sys.version_info[:3])),
213
+ torch=_detect_torch(),
214
+ gpus=gpus,
215
+ cuda_toolkit_version=_detect_nvcc(),
216
+ rocm_installed=shutil.which("rocm-smi") is not None,
217
+ nvidia_smi_present=shutil.which("nvidia-smi") is not None,
218
+ rocm_smi_present=shutil.which("rocm-smi") is not None,
219
+ vllm_installed=vllm_installed,
220
+ vllm_version=vllm_version,
221
+ ollama_endpoint=ollama_ep,
222
+ ollama_models=ollama_models,
223
+ notes=notes,
224
+ )
225
+
226
+
227
+ if __name__ == "__main__":
228
+ print(detect_environment().model_dump_json(indent=2))
sensorflow/retro/inference/vllm_server/benchmark.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Latency/throughput benchmark for an OpenAI-compatible LLM endpoint.
3
+
4
+ Measures, over N requests against a live endpoint:
5
+ * TTFT (time to first streamed token): P50 / P95 / P99
6
+ * end-to-end latency percentiles
7
+ * completion tokens/sec (per request and aggregate)
8
+ * warm vs cold: the first request after startup is reported separately
9
+
10
+ HONESTY NOTE: this script was written on a macOS machine where vLLM cannot
11
+ run. It has NOT been executed against a vLLM server here and this repository
12
+ contains NO vLLM benchmark numbers. Run it yourself on a CUDA/ROCm host:
13
+
14
+ python benchmark.py --base-url http://gpu-host:8001/v1 --n 32
15
+
16
+ It also works against Ollama's OpenAI-compatible endpoint
17
+ (http://localhost:11434/v1) — numbers from that path are Ollama-on-CPU/Metal
18
+ numbers and must be labeled as such, never as vLLM results.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import statistics
26
+ import sys
27
+ import time
28
+ from typing import Dict, List, Optional
29
+
30
+ import httpx
31
+
32
+ PROMPTS = [
33
+ "Summarize why time-to-collision alone is an insufficient safety metric.",
34
+ "List three causes of phantom braking in camera-based perception stacks.",
35
+ "Explain the difference between a false negative and a missed detection track.",
36
+ "Describe how rain degrades LiDAR point cloud density at range.",
37
+ ]
38
+
39
+
40
+ def percentile(values: List[float], p: float) -> float:
41
+ if not values:
42
+ return float("nan")
43
+ s = sorted(values)
44
+ idx = min(len(s) - 1, max(0, round(p / 100 * (len(s) - 1))))
45
+ return s[idx]
46
+
47
+
48
+ def stream_once(client: httpx.Client, base_url: str, model: str, prompt: str,
49
+ max_tokens: int) -> Dict:
50
+ """One streaming chat completion; returns ttft/e2e/token counts."""
51
+ t0 = time.perf_counter()
52
+ ttft: Optional[float] = None
53
+ completion_tokens = 0
54
+ with client.stream(
55
+ "POST", f"{base_url}/chat/completions",
56
+ json={"model": model, "messages": [{"role": "user", "content": prompt}],
57
+ "max_tokens": max_tokens, "temperature": 0.0, "stream": True},
58
+ timeout=180.0,
59
+ ) as res:
60
+ res.raise_for_status()
61
+ for line in res.iter_lines():
62
+ if not line or not line.startswith("data:"):
63
+ continue
64
+ payload = line[len("data:"):].strip()
65
+ if payload == "[DONE]":
66
+ break
67
+ try:
68
+ chunk = json.loads(payload)
69
+ except json.JSONDecodeError:
70
+ continue
71
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
72
+ if delta.get("content"):
73
+ if ttft is None:
74
+ ttft = time.perf_counter() - t0
75
+ completion_tokens += 1 # one content delta ~= one token for vLLM/Ollama
76
+ e2e = time.perf_counter() - t0
77
+ return {"ttft_s": ttft if ttft is not None else e2e, "e2e_s": e2e,
78
+ "completion_tokens": completion_tokens,
79
+ "tokens_per_s": completion_tokens / e2e if e2e > 0 else 0.0}
80
+
81
+
82
+ def main() -> int:
83
+ ap = argparse.ArgumentParser(description=__doc__,
84
+ formatter_class=argparse.RawDescriptionHelpFormatter)
85
+ ap.add_argument("--base-url", default="http://localhost:8001/v1")
86
+ ap.add_argument("--model", default=None, help="default: first served model")
87
+ ap.add_argument("--n", type=int, default=16, help="number of measured (warm) requests")
88
+ ap.add_argument("--max-tokens", type=int, default=128)
89
+ ap.add_argument("--label", default=None,
90
+ help="required honesty label for the report, e.g. "
91
+ "'vLLM A100-80GB' or 'Ollama on Apple M-series (Metal)'")
92
+ args = ap.parse_args()
93
+
94
+ client = httpx.Client()
95
+ try:
96
+ res = client.get(f"{args.base_url}/models", timeout=5.0)
97
+ res.raise_for_status()
98
+ served = [m["id"] for m in res.json().get("data", [])]
99
+ except Exception as exc:
100
+ print(f"ERROR: endpoint {args.base_url} unreachable: {exc}", file=sys.stderr)
101
+ print("No benchmark numbers produced. (This is expected on the macOS "
102
+ "dev machine: vLLM cannot run here.)", file=sys.stderr)
103
+ return 2
104
+
105
+ model = args.model or (served[0] if served else None)
106
+ if not model:
107
+ print("ERROR: endpoint serves no models", file=sys.stderr)
108
+ return 2
109
+
110
+ label = args.label or f"UNLABELED endpoint at {args.base_url}"
111
+ print(f"# Benchmark: {model} @ {args.base_url}")
112
+ print(f"# Hardware label: {label}")
113
+
114
+ print("## Cold request (first after client startup)")
115
+ cold = stream_once(client, args.base_url, model, PROMPTS[0], args.max_tokens)
116
+ print(f" ttft={cold['ttft_s'] * 1000:.0f}ms e2e={cold['e2e_s']:.2f}s "
117
+ f"tokens/s={cold['tokens_per_s']:.1f}")
118
+
119
+ print(f"## Warm requests (n={args.n})")
120
+ rows = []
121
+ for i in range(args.n):
122
+ r = stream_once(client, args.base_url, model,
123
+ PROMPTS[i % len(PROMPTS)], args.max_tokens)
124
+ rows.append(r)
125
+ print(f" [{i + 1:>3}/{args.n}] ttft={r['ttft_s'] * 1000:.0f}ms "
126
+ f"e2e={r['e2e_s']:.2f}s tok/s={r['tokens_per_s']:.1f}")
127
+
128
+ ttfts = [r["ttft_s"] * 1000 for r in rows]
129
+ e2es = [r["e2e_s"] for r in rows]
130
+ tps = [r["tokens_per_s"] for r in rows]
131
+ report = {
132
+ "hardware_label": label,
133
+ "model": model,
134
+ "base_url": args.base_url,
135
+ "n_warm": args.n,
136
+ "cold": {k: round(v, 4) for k, v in cold.items()},
137
+ "ttft_ms": {"p50": round(percentile(ttfts, 50), 1),
138
+ "p95": round(percentile(ttfts, 95), 1),
139
+ "p99": round(percentile(ttfts, 99), 1)},
140
+ "e2e_s": {"p50": round(percentile(e2es, 50), 3),
141
+ "p95": round(percentile(e2es, 95), 3),
142
+ "p99": round(percentile(e2es, 99), 3)},
143
+ "tokens_per_s": {"mean": round(statistics.mean(tps), 2),
144
+ "min": round(min(tps), 2), "max": round(max(tps), 2)},
145
+ "warm_vs_cold_ttft_ratio": round(
146
+ cold["ttft_s"] * 1000 / max(percentile(ttfts, 50), 1e-9), 2),
147
+ }
148
+ print("\n## Summary (JSON)")
149
+ print(json.dumps(report, indent=2))
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(main())
sensorflow/retro/inference/vllm_server/model.env ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # vLLM model config.
2
+ MODEL_NAME=${MODEL_NAME:-meta-llama/Llama-3.1-8B-Instruct}
3
+ MAX_MODEL_LEN=${MAX_MODEL_LEN:-8192}
4
+ # QUANTIZATION: none | awq | gptq | fp8 (support differs by GPU vendor;
5
+ # see docs/retro/compatibility-matrix.md)
6
+ QUANTIZATION=${QUANTIZATION:-none}
sensorflow/retro/inference/vllm_server/runtime.env ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # vLLM runtime/resource config.
2
+ GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.90}
3
+ TENSOR_PARALLEL_SIZE=${TENSOR_PARALLEL_SIZE:-1}
4
+ DTYPE=${DTYPE:-auto}
sensorflow/retro/inference/vllm_server/server.env ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # vLLM server config — network/serving parameters.
2
+ # Every value can be overridden by exporting the variable before start_vllm.sh.
3
+ HOST=${HOST:-0.0.0.0}
4
+ PORT=${PORT:-8001}
5
+ API_KEY=${API_KEY:-} # optional --api-key
sensorflow/retro/inference/vllm_server/start_vllm.sh ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Start an OpenAI-compatible vLLM server.
3
+ #
4
+ # RUNNABLE ONLY ON CUDA/ROCm LINUX HOSTS. This script was authored on a macOS
5
+ # development machine where vLLM cannot run; it refuses to start on Darwin
6
+ # with an honest error instead of failing cryptically.
7
+ #
8
+ # Config is separated into three env files (server/model/runtime); every
9
+ # variable can be overridden from the environment:
10
+ # MODEL_NAME=Qwen/Qwen2.5-7B-Instruct PORT=8010 ./start_vllm.sh
11
+ set -euo pipefail
12
+ cd "$(dirname "$0")"
13
+
14
+ if [ "$(uname -s)" = "Darwin" ]; then
15
+ echo "ERROR: vLLM does not run on macOS (no CUDA, no ROCm)." >&2
16
+ echo "Run this script on a Linux host with an NVIDIA or AMD GPU." >&2
17
+ echo "On this machine use the 'ollama' or 'mock' backend instead." >&2
18
+ exit 2
19
+ fi
20
+
21
+ if ! command -v nvidia-smi >/dev/null 2>&1 && ! command -v rocm-smi >/dev/null 2>&1; then
22
+ echo "ERROR: neither nvidia-smi nor rocm-smi found — no supported GPU stack." >&2
23
+ exit 2
24
+ fi
25
+
26
+ # shellcheck disable=SC1091
27
+ source ./server.env
28
+ source ./model.env
29
+ source ./runtime.env
30
+
31
+ echo "Starting vLLM: model=${MODEL_NAME} host=${HOST}:${PORT}" \
32
+ "tp=${TENSOR_PARALLEL_SIZE} gpu_mem=${GPU_MEMORY_UTILIZATION}" \
33
+ "max_len=${MAX_MODEL_LEN} quant=${QUANTIZATION}"
34
+
35
+ ARGS=(
36
+ --model "${MODEL_NAME}"
37
+ --host "${HOST}"
38
+ --port "${PORT}"
39
+ --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}"
40
+ --max-model-len "${MAX_MODEL_LEN}"
41
+ --tensor-parallel-size "${TENSOR_PARALLEL_SIZE}"
42
+ --dtype "${DTYPE}"
43
+ )
44
+ [ "${QUANTIZATION}" != "none" ] && ARGS+=(--quantization "${QUANTIZATION}")
45
+ [ -n "${API_KEY}" ] && ARGS+=(--api-key "${API_KEY}")
46
+
47
+ exec python -m vllm.entrypoints.openai.api_server "${ARGS[@]}"
sensorflow/retro/metrics.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic safety metric calculators (the safety boundary).
2
+
3
+ Everything here is pure, parameterized computation — no LLM involvement.
4
+ TTC reuses the platform's SSAM rectangle conflict-point projection
5
+ (sensorflow/safety/ssam_ext.py) and cross-checks with the closed-form
6
+ distance/closing-speed ratio. Every result carries explicit
7
+ assumption/validity flags; inputs that are absent produce UNKNOWN results,
8
+ never guesses.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from sensorflow.safety.ssam_ext import projected_ttc
19
+
20
+ G = 9.80665 # m/s^2
21
+
22
+
23
+ class StoppingDistanceResult(BaseModel):
24
+ stopping_distance_m: Optional[float]
25
+ reaction_distance_m: Optional[float]
26
+ braking_distance_m: Optional[float]
27
+ effective_decel_mps2: Optional[float]
28
+ total_reaction_time_s: Optional[float]
29
+ params: Dict[str, Any]
30
+ assumptions: List[str]
31
+ unknown_reason: Optional[str] = None
32
+
33
+
34
+ def stopping_distance(velocity_mps: Optional[float],
35
+ reaction_time_s: float = 0.25,
36
+ system_latency_s: float = 0.0,
37
+ planner_latency_s: float = 0.0,
38
+ friction: float = 0.7,
39
+ grade: float = 0.0,
40
+ decel_mps2: Optional[float] = None) -> StoppingDistanceResult:
41
+ """Stopping distance = v * t_react_total + v^2 / (2 * a_eff).
42
+
43
+ a_eff comes from an explicit decel limit if provided, otherwise from
44
+ friction and road grade: a = g * (friction * cos(theta) + sin(theta)),
45
+ with theta = atan(grade); a downhill (negative) grade reduces available
46
+ deceleration. t_react_total charges perception system latency and
47
+ planner latency against the reaction budget.
48
+ """
49
+ params = {"velocity_mps": velocity_mps, "reaction_time_s": reaction_time_s,
50
+ "system_latency_s": system_latency_s,
51
+ "planner_latency_s": planner_latency_s, "friction": friction,
52
+ "grade": grade, "decel_mps2": decel_mps2}
53
+ if velocity_mps is None:
54
+ return StoppingDistanceResult(
55
+ stopping_distance_m=None, reaction_distance_m=None,
56
+ braking_distance_m=None, effective_decel_mps2=None,
57
+ total_reaction_time_s=None, params=params, assumptions=[],
58
+ unknown_reason="ego velocity missing from telemetry -> UNKNOWN")
59
+
60
+ assumptions = ["point-mass braking model on uniform surface",
61
+ "constant deceleration during braking phase",
62
+ "reaction budget includes perception system latency "
63
+ "and planner latency"]
64
+ theta = math.atan(grade)
65
+ if decel_mps2 is not None:
66
+ a_eff = float(decel_mps2) + G * math.sin(theta)
67
+ assumptions.append("deceleration limit supplied explicitly; grade "
68
+ "component added (downhill reduces)")
69
+ else:
70
+ a_eff = G * (friction * math.cos(theta) + math.sin(theta))
71
+ assumptions.append(f"deceleration derived from friction={friction} "
72
+ f"and grade={grade}")
73
+ if a_eff <= 0.05:
74
+ return StoppingDistanceResult(
75
+ stopping_distance_m=None, reaction_distance_m=None,
76
+ braking_distance_m=None, effective_decel_mps2=round(a_eff, 4),
77
+ total_reaction_time_s=None, params=params, assumptions=assumptions,
78
+ unknown_reason="effective deceleration <= 0 (grade exceeds "
79
+ "friction) -> stopping distance undefined")
80
+
81
+ t_react = reaction_time_s + system_latency_s + planner_latency_s
82
+ reaction_d = velocity_mps * t_react
83
+ braking_d = velocity_mps ** 2 / (2 * a_eff)
84
+ return StoppingDistanceResult(
85
+ stopping_distance_m=round(reaction_d + braking_d, 2),
86
+ reaction_distance_m=round(reaction_d, 2),
87
+ braking_distance_m=round(braking_d, 2),
88
+ effective_decel_mps2=round(a_eff, 3),
89
+ total_reaction_time_s=round(t_react, 3),
90
+ params=params, assumptions=assumptions)
91
+
92
+
93
+ class TTCResult(BaseModel):
94
+ ttc_s: Optional[float]
95
+ method: str
96
+ validity_flags: List[str]
97
+ closed_form_ttc_s: Optional[float] = None
98
+ unknown_reason: Optional[str] = None
99
+
100
+
101
+ def time_to_collision(distance_m: Optional[float],
102
+ closing_velocity_mps: Optional[float],
103
+ ego_length_m: float = 4.5, ego_width_m: float = 1.9,
104
+ obj_length_m: float = 0.6, obj_width_m: float = 0.6,
105
+ look_ahead_s: float = 12.0) -> TTCResult:
106
+ """TTC via the SSAM rectangle conflict-point projection (ssam_ext),
107
+ cross-checked against the closed-form gap/closing ratio.
108
+
109
+ closing_velocity_mps > 0 means the range is decreasing.
110
+ """
111
+ flags = ["constant-velocity extrapolation (no acceleration model)",
112
+ "straight-line relative motion assumed",
113
+ "rectangle footprint projection (ssam_ext.projected_ttc)"]
114
+ if distance_m is None or closing_velocity_mps is None:
115
+ return TTCResult(ttc_s=None, method="ssam_ext.projected_ttc",
116
+ validity_flags=flags,
117
+ unknown_reason="distance or relative velocity missing "
118
+ "from telemetry -> UNKNOWN")
119
+ if closing_velocity_mps <= 0:
120
+ flags.append("object not closing: TTC undefined (no predicted collision)")
121
+ return TTCResult(ttc_s=None, method="ssam_ext.projected_ttc",
122
+ validity_flags=flags)
123
+
124
+ ego_state = {"x": 0.0, "y": 0.0, "speed": float(closing_velocity_mps),
125
+ "heading": 0.0}
126
+ obj_state = {"x": float(distance_m), "y": 0.0, "speed": 0.0, "heading": 0.0}
127
+ ttc = projected_ttc(ego_state, (ego_length_m, ego_width_m),
128
+ obj_state, (obj_length_m, obj_width_m),
129
+ look_ahead=look_ahead_s, dt=0.02)
130
+ gap = max(distance_m - (ego_length_m + obj_length_m) / 2, 0.0)
131
+ closed_form = gap / closing_velocity_mps
132
+ if ttc is None:
133
+ flags.append(f"no projected collision within {look_ahead_s}s look-ahead")
134
+ return TTCResult(ttc_s=None if ttc is None else round(ttc, 3),
135
+ method="ssam_ext.projected_ttc",
136
+ validity_flags=flags,
137
+ closed_form_ttc_s=round(closed_form, 3))
138
+
139
+
140
+ class SCRImpactResult(BaseModel):
141
+ scr_baseline: Optional[float]
142
+ scr_candidate: Optional[float]
143
+ scr_impact: Optional[float] # candidate - baseline
144
+ criticality_policy: str
145
+ denominator: Optional[int]
146
+ unknown_reason: Optional[str] = None
147
+
148
+
149
+ def scr_impact(evaluation_context: Optional[Dict[str, Any]]) -> SCRImpactResult:
150
+ """Safety-critical recall delta against the policy-defined criticality
151
+ population (objects inside 1.5x stopping distance, closing, VRU or
152
+ collision-relevant mass — see SFS-EVAL-005 EVAL-SCR-01 [SYNTHETIC])."""
153
+ policy = ("critical = inside 1.5x stopping distance AND closing AND "
154
+ "(VRU class OR collision-relevant mass)")
155
+ ctx = evaluation_context or {}
156
+ n = ctx.get("critical_object_count")
157
+ mb = ctx.get("baseline_missed_critical")
158
+ mc = ctx.get("candidate_missed_critical")
159
+ if n is None or mb is None or mc is None or n <= 0:
160
+ return SCRImpactResult(scr_baseline=None, scr_candidate=None,
161
+ scr_impact=None, criticality_policy=policy,
162
+ denominator=None,
163
+ unknown_reason="criticality-context counts absent "
164
+ "-> SCR impact UNKNOWN (inadmissible "
165
+ "as launch evidence)")
166
+ scr_b = 1.0 - mb / n
167
+ scr_c = 1.0 - mc / n
168
+ return SCRImpactResult(scr_baseline=round(scr_b, 6),
169
+ scr_candidate=round(scr_c, 6),
170
+ scr_impact=round(scr_c - scr_b, 6),
171
+ criticality_policy=policy, denominator=int(n))
172
+
173
+
174
+ class BehavioralImpactResult(BaseModel):
175
+ observed_action: Optional[str]
176
+ observed_decel_mps2: Optional[float]
177
+ corrected_action: Optional[str]
178
+ corrected_decel_mps2: Optional[float]
179
+ action_changed: Optional[bool]
180
+ decel_delta_mps2: Optional[float]
181
+ response_delay_s: Optional[float]
182
+ consequence: str
183
+ unknown_reason: Optional[str] = None
184
+
185
+
186
+ def behavioral_impact(observed: Optional[Dict[str, Any]],
187
+ counterfactual: Optional[Dict[str, Any]]) -> BehavioralImpactResult:
188
+ """Observed planner response vs the corrected-perception counterfactual.
189
+
190
+ The counterfactual must be supplied by the evaluation artifact (replay or
191
+ simulation output); this function only compares — it never invents one.
192
+ """
193
+ if not observed:
194
+ return BehavioralImpactResult(
195
+ observed_action=None, observed_decel_mps2=None,
196
+ corrected_action=None, corrected_decel_mps2=None,
197
+ action_changed=None, decel_delta_mps2=None, response_delay_s=None,
198
+ consequence="UNKNOWN",
199
+ unknown_reason="planner response missing from artifact")
200
+ if not counterfactual:
201
+ return BehavioralImpactResult(
202
+ observed_action=observed.get("action"),
203
+ observed_decel_mps2=observed.get("decel_mps2"),
204
+ corrected_action=None, corrected_decel_mps2=None,
205
+ action_changed=None, decel_delta_mps2=None, response_delay_s=None,
206
+ consequence="UNKNOWN",
207
+ unknown_reason="corrected-perception counterfactual not provided "
208
+ "in artifact -> behavioral delta UNKNOWN")
209
+ obs_a, cf_a = observed.get("action"), counterfactual.get("action")
210
+ obs_d = float(observed.get("decel_mps2") or 0.0)
211
+ cf_d = float(counterfactual.get("decel_mps2") or 0.0)
212
+ obs_t = observed.get("response_time_s")
213
+ cf_t = counterfactual.get("response_time_s")
214
+ delay = (round(float(obs_t) - float(cf_t), 3)
215
+ if obs_t is not None and cf_t is not None else None)
216
+ changed = obs_a != cf_a
217
+ delta = round(obs_d - cf_d, 3)
218
+ if delay is not None and delay > 0.5:
219
+ consequence = (f"perception error DELAYED the planner response by "
220
+ f"{delay:.2f} s ('{cf_a}' at t={cf_t}s became '{obs_a}' "
221
+ f"at t={obs_t}s) -> reaction-time budget consumed, "
222
+ "residual collision risk")
223
+ elif changed and delta > 3.0:
224
+ consequence = (f"perception error converted '{cf_a}' into '{obs_a}' with "
225
+ f"{delta:+.1f} m/s^2 excess deceleration (unwarranted "
226
+ "intervention)")
227
+ elif changed and delta < -3.0:
228
+ consequence = (f"perception error suppressed '{cf_a}' "
229
+ f"({delta:+.1f} m/s^2 missing deceleration -> residual "
230
+ "collision risk)")
231
+ elif changed:
232
+ consequence = f"planner action changed ('{cf_a}' -> '{obs_a}'), moderate magnitude"
233
+ else:
234
+ consequence = "no behavioral change attributable to the perception error"
235
+ return BehavioralImpactResult(
236
+ observed_action=obs_a, observed_decel_mps2=obs_d,
237
+ corrected_action=cf_a, corrected_decel_mps2=cf_d,
238
+ action_changed=changed, decel_delta_mps2=delta, response_delay_s=delay,
239
+ consequence=consequence)
sensorflow/retro/policy.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic severity framework and launch-recommendation gate.
2
+
3
+ This module OWNS the safety boundary. The LLM may propose a severity with
4
+ evidence; this engine validates or overrides it, records divergence, and is
5
+ the ONLY producer of launch determinations.
6
+
7
+ Severity model (asymmetric FN/FP costs, context-dependent — deliberately NOT
8
+ "every FN outranks every FP"):
9
+ FALSE NEGATIVE cost ~ VRU weight x safety-relevant-distance factor x
10
+ relative-motion factor x remaining-reaction-time factor
11
+ FALSE POSITIVE cost ~ intervention magnitude x traffic disruption exposure
12
+ A benign distant non-closing FN scores below a hard-braking phantom FP with
13
+ a tailgater; the test suite pins this ordering.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from sensorflow.retro.scorecard import LaunchRecommendation, Severity
23
+
24
+ POLICY_VERSION = "retro-policy/1.0.0"
25
+
26
+ # Versioned policy configuration: all thresholds live here, not in code paths.
27
+ POLICY_CONFIG: Dict[str, Any] = {
28
+ "version": POLICY_VERSION,
29
+ "vru_weights": {
30
+ "pedestrian": 1.0, "cyclist": 0.95, "motorcycle": 0.9,
31
+ "wheelchair": 1.0, "vehicle": 0.6, "truck": 0.65, "bus": 0.65,
32
+ "unknown": 0.5, "debris": 0.15, "plastic_bag": 0.05, "static": 0.2,
33
+ },
34
+ "fn_severity_cutoffs": {"critical": 0.45, "disruptive": 0.18},
35
+ "fp_severity_cutoffs": {"critical": 0.55, "disruptive": 0.28},
36
+ "hard_brake_g": 0.35, # from SFS-SAFE-001 REQ-FP-02 [SYNTHETIC]
37
+ "tailgater_gap_s": 1.5,
38
+ "scr_regression_tolerance": 0.002, # 0.2 pp, SFS-LAUNCH-004 GATE-02 [SYNTHETIC]
39
+ "collision_risk_ttc_s": 1.5,
40
+ }
41
+
42
+
43
+ class SeverityContext(BaseModel):
44
+ """Deterministic inputs to the severity computation (facts + derived)."""
45
+ failure_type: str # FALSE_NEGATIVE | FALSE_POSITIVE | ...
46
+ object_class: Optional[str] = None # ground-truth class
47
+ distance_m: Optional[float] = None
48
+ stopping_distance_m: Optional[float] = None
49
+ closing_velocity_mps: Optional[float] = None
50
+ ttc_s: Optional[float] = None
51
+ total_reaction_time_s: Optional[float] = None
52
+ collision_occurred: bool = False
53
+ near_miss: bool = False
54
+ intervention_decel_mps2: Optional[float] = None # observed planner decel
55
+ following_gap_s: Optional[float] = None # rear traffic time gap
56
+ ego_speed_mps: Optional[float] = None
57
+
58
+
59
+ class SeverityResult(BaseModel):
60
+ severity: Severity
61
+ score: float
62
+ rule_trace: List[str] = Field(default_factory=list)
63
+ computable: bool = True
64
+
65
+
66
+ def _fn_severity(ctx: SeverityContext, cfg: Dict[str, Any],
67
+ trace: List[str]) -> Tuple[Severity, float]:
68
+ w = cfg["vru_weights"].get((ctx.object_class or "unknown").lower(), 0.5)
69
+ trace.append(f"FN branch: class '{ctx.object_class}' weight={w}")
70
+
71
+ if ctx.distance_m is not None and ctx.stopping_distance_m:
72
+ ratio = ctx.distance_m / ctx.stopping_distance_m
73
+ if ratio <= 1.0:
74
+ dist_f = 1.0
75
+ elif ratio <= 1.5:
76
+ dist_f = 0.6
77
+ elif ratio <= 3.0:
78
+ dist_f = 0.25
79
+ else:
80
+ dist_f = 0.05
81
+ trace.append(f"distance {ctx.distance_m:.1f} m = {ratio:.2f}x stopping "
82
+ f"distance -> factor {dist_f}")
83
+ else:
84
+ dist_f = 0.5
85
+ trace.append("distance vs stopping distance not computable -> neutral 0.5")
86
+
87
+ closing = ctx.closing_velocity_mps or 0.0
88
+ if closing > 0:
89
+ motion_f = min(1.0, 0.3 + closing / 15.0)
90
+ trace.append(f"closing at {closing:.1f} m/s -> motion factor {motion_f:.2f}")
91
+ else:
92
+ motion_f = 0.1
93
+ trace.append("not closing -> motion factor 0.1")
94
+
95
+ if ctx.ttc_s is not None and ctx.total_reaction_time_s is not None:
96
+ remaining = ctx.ttc_s - ctx.total_reaction_time_s
97
+ if remaining <= 0:
98
+ rrt_f = 1.0
99
+ elif remaining < 1.0:
100
+ rrt_f = 0.8
101
+ elif remaining < 2.5:
102
+ rrt_f = 0.5
103
+ else:
104
+ rrt_f = 0.2
105
+ trace.append(f"remaining reaction time {remaining:.2f} s -> factor {rrt_f}")
106
+ elif closing > 0:
107
+ rrt_f = 0.6
108
+ trace.append("TTC/reaction budget unknown while closing -> conservative 0.6")
109
+ else:
110
+ rrt_f = 0.1
111
+ trace.append("no predicted collision path -> reaction factor 0.1")
112
+
113
+ score = w * dist_f * (0.5 * motion_f + 0.5 * rrt_f)
114
+ cuts = cfg["fn_severity_cutoffs"]
115
+ if ctx.collision_occurred:
116
+ trace.append("collision occurred -> FATAL override")
117
+ return Severity.FATAL, max(score, 1.0)
118
+ if score >= cuts["critical"] or (ctx.near_miss and score >= cuts["disruptive"]):
119
+ return Severity.CRITICAL, score
120
+ if score >= cuts["disruptive"]:
121
+ return Severity.DISRUPTIVE, score
122
+ if ctx.near_miss:
123
+ trace.append("near-miss recorded despite low computed score -> "
124
+ "DISRUPTIVE floor")
125
+ return Severity.DISRUPTIVE, score
126
+ return Severity.BENIGN, score
127
+
128
+
129
+ def _fp_severity(ctx: SeverityContext, cfg: Dict[str, Any],
130
+ trace: List[str]) -> Tuple[Severity, float]:
131
+ decel = ctx.intervention_decel_mps2 or 0.0
132
+ intervention_f = min(1.0, decel / 9.0)
133
+ trace.append(f"FP branch: intervention decel {decel:.1f} m/s^2 "
134
+ f"({decel / 9.80665:.2f} g) -> factor {intervention_f:.2f}")
135
+
136
+ if ctx.following_gap_s is not None:
137
+ if ctx.following_gap_s < 1.0:
138
+ disruption_f = 1.0
139
+ elif ctx.following_gap_s < cfg["tailgater_gap_s"]:
140
+ disruption_f = 0.8
141
+ elif ctx.following_gap_s < 3.0:
142
+ disruption_f = 0.5
143
+ else:
144
+ disruption_f = 0.3
145
+ trace.append(f"following gap {ctx.following_gap_s:.1f} s -> "
146
+ f"disruption factor {disruption_f}")
147
+ else:
148
+ disruption_f = 0.4
149
+ trace.append("following traffic unknown -> disruption factor 0.4 (assumed)")
150
+
151
+ speed_f = min(1.0, (ctx.ego_speed_mps or 0.0) / 30.0)
152
+ trace.append(f"ego speed factor {speed_f:.2f}")
153
+
154
+ score = intervention_f * (0.55 + 0.45 * disruption_f) * (0.6 + 0.4 * speed_f)
155
+ cuts = cfg["fp_severity_cutoffs"]
156
+ hard_brake = decel >= cfg["hard_brake_g"] * 9.80665
157
+ tailgated = (ctx.following_gap_s is not None
158
+ and ctx.following_gap_s < cfg["tailgater_gap_s"])
159
+ if ctx.collision_occurred:
160
+ trace.append("collision occurred (induced) -> FATAL override")
161
+ return Severity.FATAL, max(score, 1.0)
162
+ if hard_brake and tailgated:
163
+ trace.append(f"hard brake (>= {cfg['hard_brake_g']} g) with rear gap "
164
+ f"< {cfg['tailgater_gap_s']} s -> CRITICAL rule "
165
+ "(REQ-FP-02 [SYNTHETIC])")
166
+ return Severity.CRITICAL, max(score, cuts["critical"])
167
+ if score >= cuts["critical"]:
168
+ return Severity.CRITICAL, score
169
+ if score >= cuts["disruptive"]:
170
+ return Severity.DISRUPTIVE, score
171
+ return Severity.BENIGN, score
172
+
173
+
174
+ def compute_severity(ctx: SeverityContext,
175
+ config: Optional[Dict[str, Any]] = None) -> SeverityResult:
176
+ """Deterministic severity from contextual evidence. Pure function."""
177
+ cfg = config or POLICY_CONFIG
178
+ trace: List[str] = [f"policy {cfg['version']}"]
179
+ ft = ctx.failure_type.upper()
180
+ if ft in ("FALSE_NEGATIVE", "MISSED_DETECTION", "LATE_DETECTION"):
181
+ sev, score = _fn_severity(ctx, cfg, trace)
182
+ elif ft in ("FALSE_POSITIVE", "PHANTOM_OBJECT", "MISCLASSIFICATION_FP"):
183
+ sev, score = _fp_severity(ctx, cfg, trace)
184
+ elif ft == "MISCLASSIFICATION":
185
+ # Consequence-dominant: score both directions, take the worse.
186
+ t1: List[str] = []
187
+ t2: List[str] = []
188
+ s1 = _fn_severity(ctx, cfg, t1)
189
+ s2 = _fp_severity(ctx, cfg, t2)
190
+ order = [Severity.BENIGN, Severity.DISRUPTIVE, Severity.CRITICAL, Severity.FATAL]
191
+ if order.index(s1[0]) >= order.index(s2[0]):
192
+ sev, score = s1
193
+ trace += ["misclassification scored on FN consequence branch"] + t1
194
+ else:
195
+ sev, score = s2
196
+ trace += ["misclassification scored on FP consequence branch"] + t2
197
+ else:
198
+ trace.append(f"failure type '{ctx.failure_type}' has no severity model "
199
+ "-> not computable, conservative DISRUPTIVE floor")
200
+ return SeverityResult(severity=Severity.DISRUPTIVE, score=0.0,
201
+ rule_trace=trace, computable=False)
202
+ return SeverityResult(severity=sev, score=round(score, 4), rule_trace=trace)
203
+
204
+
205
+ SEVERITY_ORDER = [Severity.BENIGN, Severity.DISRUPTIVE, Severity.CRITICAL,
206
+ Severity.FATAL]
207
+
208
+
209
+ class SeverityAdjudication(BaseModel):
210
+ policy_severity: Severity
211
+ ai_proposed_severity: Optional[Severity]
212
+ final_severity: Severity # always the policy severity
213
+ divergence: bool
214
+ divergence_note: str = ""
215
+
216
+
217
+ def adjudicate_severity(policy: SeverityResult,
218
+ ai_proposed: Optional[Severity]) -> SeverityAdjudication:
219
+ """Policy validates/overrides the LLM proposal; divergence is recorded
220
+ and (by the caller) flagged for human review."""
221
+ divergence = ai_proposed is not None and ai_proposed != policy.severity
222
+ note = ""
223
+ if divergence:
224
+ direction = ("understated" if SEVERITY_ORDER.index(ai_proposed)
225
+ < SEVERITY_ORDER.index(policy.severity) else "overstated")
226
+ note = (f"AI proposed {ai_proposed.value} but deterministic policy "
227
+ f"computed {policy.severity.value} (AI {direction} severity); "
228
+ "policy severity is authoritative and the divergence requires "
229
+ "human review")
230
+ return SeverityAdjudication(policy_severity=policy.severity,
231
+ ai_proposed_severity=ai_proposed,
232
+ final_severity=policy.severity,
233
+ divergence=divergence, divergence_note=note)
234
+
235
+
236
+ class LaunchDecision(BaseModel):
237
+ recommendation: LaunchRecommendation
238
+ rationale: List[str]
239
+ policy_version: str = POLICY_VERSION
240
+
241
+
242
+ def launch_gate(severity: Severity,
243
+ has_evidence_gaps: bool,
244
+ scr_impact: Optional[float],
245
+ scr_significant: Optional[bool],
246
+ config: Optional[Dict[str, Any]] = None) -> LaunchDecision:
247
+ """Deterministic launch recommendation. INSUFFICIENT_EVIDENCE never
248
+ becomes PASS (SFS-LAUNCH-004 GATE-03 [SYNTHETIC], enforced here)."""
249
+ cfg = config or POLICY_CONFIG
250
+ rationale: List[str] = [f"policy {cfg['version']}"]
251
+
252
+ if has_evidence_gaps:
253
+ rationale.append("required evidence fields are UNKNOWN -> "
254
+ "INSUFFICIENT_EVIDENCE (can never be converted to PASS; "
255
+ "resolve evidence gaps and re-run)")
256
+ return LaunchDecision(recommendation=LaunchRecommendation.INSUFFICIENT_EVIDENCE,
257
+ rationale=rationale)
258
+
259
+ if severity in (Severity.CRITICAL, Severity.FATAL):
260
+ rationale.append(f"unresolved {severity.value} severity -> FAIL (GATE-01)")
261
+ return LaunchDecision(recommendation=LaunchRecommendation.FAIL,
262
+ rationale=rationale)
263
+
264
+ tol = cfg["scr_regression_tolerance"]
265
+ if scr_impact is not None and scr_impact < -tol and scr_significant:
266
+ rationale.append(f"safety-critical recall regressed {scr_impact:+.4f} "
267
+ f"beyond tolerance {tol} with statistical significance "
268
+ "-> FAIL (GATE-02)")
269
+ return LaunchDecision(recommendation=LaunchRecommendation.FAIL,
270
+ rationale=rationale)
271
+
272
+ if severity == Severity.DISRUPTIVE:
273
+ rationale.append("DISRUPTIVE severity -> CONDITIONAL_PASS pending "
274
+ "mitigation tracking")
275
+ return LaunchDecision(recommendation=LaunchRecommendation.CONDITIONAL_PASS,
276
+ rationale=rationale)
277
+
278
+ if scr_impact is not None and scr_impact < -tol and scr_significant is None:
279
+ rationale.append(f"SCR delta {scr_impact:+.4f} beyond tolerance but "
280
+ "significance not established -> CONDITIONAL_PASS "
281
+ "pending seqeval confirmation")
282
+ return LaunchDecision(recommendation=LaunchRecommendation.CONDITIONAL_PASS,
283
+ rationale=rationale)
284
+
285
+ rationale.append("BENIGN severity, no significant safety-critical recall "
286
+ "regression -> PASS")
287
+ return LaunchDecision(recommendation=LaunchRecommendation.PASS,
288
+ rationale=rationale)
sensorflow/retro/rag/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Safety-case RAG: parser -> chunking -> embeddings -> store -> retriever.
2
+
3
+ The seed corpus is SYNTHETIC demonstration material. Every synthetic rule is
4
+ tagged SYNTHETIC_EXAMPLE / NOT_A_REAL_STANDARD in both content and metadata;
5
+ SOTIF (ISO 21448) entries are honest concept paraphrases labeled
6
+ PARAPHRASE_NOT_STANDARD_TEXT. The retriever ALWAYS returns
7
+ {source, document, version, section, retrieved_text, relevance_score}.
8
+ """
sensorflow/retro/rag/chunking.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document parsing and configurable chunking.
2
+
3
+ Documents are markdown-ish text with `## section` headings. The parser splits
4
+ into sections (keeping section titles as metadata), and the chunker splits
5
+ long sections into overlapping windows measured in words.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Dict, List
12
+
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class Chunk(BaseModel):
17
+ chunk_id: str
18
+ text: str
19
+ metadata: Dict[str, str]
20
+
21
+
22
+ def parse_sections(text: str) -> List[Dict[str, str]]:
23
+ """Split a document into {section, text} blocks on '## ' headings."""
24
+ sections: List[Dict[str, str]] = []
25
+ current_title = "preamble"
26
+ current_lines: List[str] = []
27
+ for line in text.splitlines():
28
+ m = re.match(r"^##\s+(.*)", line)
29
+ if m:
30
+ if current_lines and "".join(current_lines).strip():
31
+ sections.append({"section": current_title,
32
+ "text": "\n".join(current_lines).strip()})
33
+ current_title = m.group(1).strip()
34
+ current_lines = []
35
+ else:
36
+ current_lines.append(line)
37
+ if current_lines and "".join(current_lines).strip():
38
+ sections.append({"section": current_title,
39
+ "text": "\n".join(current_lines).strip()})
40
+ return sections
41
+
42
+
43
+ def chunk_words(text: str, chunk_size: int = 160, overlap: int = 40) -> List[str]:
44
+ """Overlapping word-window chunking. chunk_size/overlap are in words."""
45
+ if chunk_size <= 0:
46
+ raise ValueError("chunk_size must be positive")
47
+ if overlap >= chunk_size:
48
+ raise ValueError("overlap must be smaller than chunk_size")
49
+ words = text.split()
50
+ if len(words) <= chunk_size:
51
+ return [text.strip()] if text.strip() else []
52
+ step = chunk_size - overlap
53
+ chunks = []
54
+ for start in range(0, len(words), step):
55
+ window = words[start:start + chunk_size]
56
+ if not window:
57
+ break
58
+ chunks.append(" ".join(window))
59
+ if start + chunk_size >= len(words):
60
+ break
61
+ return chunks
62
+
63
+
64
+ def chunk_document(doc_id: str, text: str, base_metadata: Dict[str, str],
65
+ chunk_size: int = 160, overlap: int = 40) -> List[Chunk]:
66
+ """Parse into sections then chunk each section, propagating metadata."""
67
+ chunks: List[Chunk] = []
68
+ for si, sec in enumerate(parse_sections(text)):
69
+ for ci, piece in enumerate(chunk_words(sec["text"], chunk_size, overlap)):
70
+ meta = dict(base_metadata)
71
+ meta["section"] = sec["section"]
72
+ chunks.append(Chunk(chunk_id=f"{doc_id}:s{si}:c{ci}", text=piece,
73
+ metadata=meta))
74
+ return chunks
sensorflow/retro/rag/corpus.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed safety-case corpus.
2
+
3
+ EVERYTHING HERE IS SYNTHETIC DEMONSTRATION MATERIAL. Every synthetic rule is
4
+ tagged SYNTHETIC_EXAMPLE / NOT_A_REAL_STANDARD in the text itself AND in the
5
+ chunk metadata, so no rendering path can strip the label. The SOTIF document
6
+ is an honest concept-level paraphrase of ISO 21448 ideas, explicitly labeled
7
+ PARAPHRASE_NOT_STANDARD_TEXT — it contains no standard text and no clause
8
+ numbers, and must never be cited as the standard.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, List
14
+
15
+ SYNTH = "SYNTHETIC_EXAMPLE / NOT_A_REAL_STANDARD"
16
+
17
+ SEED_DOCUMENTS: List[Dict] = [
18
+ {
19
+ "doc_id": "SFS-SAFE-001",
20
+ "document": "Internal Perception Safety Requirements",
21
+ "source": "sensorflow-internal",
22
+ "version": "2.1",
23
+ "jurisdiction": "internal",
24
+ "doc_type": "safety_requirement",
25
+ "effective_date": "2026-01-15",
26
+ "synthetic": "true",
27
+ "label": SYNTH,
28
+ "text": f"""[{SYNTH}] Internal Perception Safety Requirements v2.1.
29
+
30
+ ## VRU detection within stopping distance
31
+ [{SYNTH}] REQ-VRU-01: A vulnerable road user (pedestrian, cyclist,
32
+ motorcyclist) that is inside the ego vehicle's current stopping distance
33
+ envelope MUST be detected and tracked with safety-critical recall of at
34
+ least 0.999 per exposure hour. A false negative (missed detection) of a
35
+ pedestrian within stopping distance is classified at minimum CRITICAL and
36
+ requires a retrospective before any launch decision. Degraded weather (rain,
37
+ fog, spray) does not relax this requirement; it tightens the sensor fusion
38
+ redundancy obligations in REQ-VRU-03.
39
+
40
+ ## False positive braking limits
41
+ [{SYNTH}] REQ-FP-02: Phantom braking caused by misclassified static or
42
+ low-mass clutter (plastic bag, cardboard, tire shred, road debris) MUST NOT
43
+ produce a hard brake exceeding 0.35 g when no collision-relevant object is
44
+ present. A phantom hard brake above this magnitude with following traffic
45
+ present is classified at minimum DISRUPTIVE, and CRITICAL when rear
46
+ time-gap is under 1.5 seconds, because induced rear-end collision risk
47
+ transfers harm to other road users.
48
+
49
+ ## Detection latency budget
50
+ [{SYNTH}] REQ-LAT-04: End-to-end perception-to-planner reaction latency
51
+ (sensor timestamp to planner action) MUST NOT exceed 300 ms at P99 for
52
+ objects inside the safety-critical zone. Latency consumed by perception
53
+ reduces the remaining human or system reaction time budget and is charged
54
+ against stopping distance in the safety case.
55
+
56
+ ## Sensor fusion disagreement
57
+ [{SYNTH}] REQ-VRU-03: When camera and LiDAR disagree on the existence of a
58
+ VRU-class object inside 2x stopping distance, the stack MUST treat the
59
+ object as present (existence-favoring fusion) until disproven by two
60
+ consecutive frames of agreement.""",
61
+ },
62
+ {
63
+ "doc_id": "SFS-LAUNCH-004",
64
+ "document": "Launch Criteria and Release Gate Policy",
65
+ "source": "sensorflow-internal",
66
+ "version": "3.0",
67
+ "jurisdiction": "internal",
68
+ "doc_type": "launch_criteria",
69
+ "effective_date": "2026-03-01",
70
+ "synthetic": "true",
71
+ "label": SYNTH,
72
+ "text": f"""[{SYNTH}] Launch Criteria and Release Gate Policy v3.0.
73
+
74
+ ## Launch gate conditions
75
+ [{SYNTH}] GATE-01: A candidate model MUST NOT receive a PASS launch
76
+ recommendation while any retrospective scorecard carries an unresolved
77
+ severity of CRITICAL or FATAL. GATE-02: Safety-critical recall (SCR) on the
78
+ criticality-weighted evaluation population must not regress versus baseline
79
+ beyond the policy tolerance of 0.2 percentage points with statistical
80
+ significance. GATE-03: A determination of INSUFFICIENT_EVIDENCE can never be
81
+ converted into PASS by any agent, human or automated; the only allowed
82
+ transitions are to CONDITIONAL_PASS after new evidence or to FAIL.
83
+
84
+ ## Human review triggers
85
+ [{SYNTH}] GATE-05: Human review is mandatory when (a) severity is CRITICAL
86
+ or FATAL, (b) the AI-proposed severity diverges from the deterministic
87
+ policy severity, (c) any evidence field required by the scorecard is
88
+ UNKNOWN, or (d) the analysis relied on fewer than two independent evidence
89
+ sources.
90
+
91
+ ## Disengagement and collision-risk events
92
+ [{SYNTH}] GATE-07: Any event with a collision-risk trajectory (predicted
93
+ time-to-collision below 1.5 s against a VRU) or an actual disengagement
94
+ attributed to perception MUST be retrospectively analyzed before the next
95
+ release train, with the scorecard archived and auditable.""",
96
+ },
97
+ {
98
+ "doc_id": "SFS-PERC-002",
99
+ "document": "Perception Requirements in Degraded Conditions",
100
+ "source": "sensorflow-internal",
101
+ "version": "1.4",
102
+ "jurisdiction": "internal",
103
+ "doc_type": "perception_requirement",
104
+ "effective_date": "2025-11-20",
105
+ "synthetic": "true",
106
+ "label": SYNTH,
107
+ "text": f"""[{SYNTH}] Perception Requirements in Degraded Conditions v1.4.
108
+
109
+ ## Rain and spray degradation
110
+ [{SYNTH}] REQ-WX-01: In measured rain rates up to 25 mm/h, pedestrian
111
+ detection recall inside the safety-critical zone must degrade by no more
112
+ than 0.5 percentage points versus clear-weather baseline. LiDAR point
113
+ density loss and camera contrast loss must be compensated by fusion
114
+ weighting, not by raising confidence thresholds, because threshold raising
115
+ silently trades false positives for missed VRUs.
116
+
117
+ ## Confidence calibration
118
+ [{SYNTH}] REQ-CAL-02: Detection confidence must be calibrated such that a
119
+ reported confidence of 0.5 corresponds to an empirical precision between
120
+ 0.45 and 0.55 on the rolling evaluation window. Systematic underconfidence
121
+ in rain is a known failure mode and must be tracked as a calibration defect,
122
+ not compensated with global threshold changes.
123
+
124
+ ## Object class stability
125
+ [{SYNTH}] REQ-CLS-03: Class flicker (an object toggling between classes such
126
+ as vehicle and unknown or debris across consecutive frames) inside 2x
127
+ stopping distance must trigger existence-favoring handling and is reportable
128
+ evidence in any retrospective involving misclassification.""",
129
+ },
130
+ {
131
+ "doc_id": "SFS-ODD-003",
132
+ "document": "Operational Design Domain Definitions",
133
+ "source": "sensorflow-internal",
134
+ "version": "2.0",
135
+ "jurisdiction": "internal",
136
+ "doc_type": "odd_definition",
137
+ "effective_date": "2025-09-01",
138
+ "synthetic": "true",
139
+ "label": SYNTH,
140
+ "text": f"""[{SYNTH}] Operational Design Domain Definitions v2.0.
141
+
142
+ ## Urban ODD envelope
143
+ [{SYNTH}] ODD-URB-01: The urban ODD covers divided and undivided surface
144
+ streets with posted speeds up to 45 mph (20.1 m/s), signalized and
145
+ unsignalized intersections, and marked crosswalks. Ego speed above the
146
+ posted limit voids the ODD envelope for analysis purposes.
147
+
148
+ ## Weather boundaries
149
+ [{SYNTH}] ODD-WX-02: Operation is inside ODD in light-to-moderate rain
150
+ (under 25 mm/h) with functional wipers and defog. Heavy rain above 25 mm/h,
151
+ standing water, and night-plus-heavy-rain combinations are OUTSIDE the
152
+ current ODD; events occurring there are ODD-exit events, and the correct
153
+ system response is a minimal-risk maneuver, not continued operation.
154
+
155
+ ## Analysis obligations
156
+ [{SYNTH}] ODD-AN-03: Every retrospective must state whether the event was
157
+ inside or outside ODD, because requirements applicability, severity
158
+ weighting, and launch gating differ. Outside-ODD events still require
159
+ analysis of whether the ODD-exit detection itself functioned.""",
160
+ },
161
+ {
162
+ "doc_id": "SFS-RETRO-HIST-01",
163
+ "document": "Historical Retrospectives Digest",
164
+ "source": "sensorflow-internal",
165
+ "version": "1.0",
166
+ "jurisdiction": "internal",
167
+ "doc_type": "historical_retrospective",
168
+ "effective_date": "2025-12-10",
169
+ "synthetic": "true",
170
+ "label": SYNTH,
171
+ "text": f"""[{SYNTH}] Historical Retrospectives Digest v1.0.
172
+
173
+ ## RETRO-2025-014 phantom braking cluster
174
+ [{SYNTH}] Twelve phantom hard-brake events in one quarter were traced to
175
+ low-mass road debris (plastic bags, mylar balloons) being classified as
176
+ vehicle or unknown-obstacle with high confidence at 30-60 m range. Root
177
+ cause hypothesis confirmed: training distribution lacked wind-blown
178
+ deformable objects; the tracker latched a spurious velocity toward ego from
179
+ bag motion, producing a false closing-velocity estimate and an unwarranted
180
+ emergency braking response. Fix: hard-negative mining of deformable clutter
181
+ plus track-consistency gating on mass-plausibility. Post-fix phantom brake
182
+ rate fell 87 percent.
183
+
184
+ ## RETRO-2025-021 missed pedestrian in rain
185
+ [{SYNTH}] A pedestrian in dark clothing crossing mid-block in moderate rain
186
+ at night was detected 1.9 s later than required, inside stopping distance.
187
+ Camera contrast was degraded and LiDAR returns were attenuated by spray;
188
+ fusion confidence stayed below the tracking threshold for 6 consecutive
189
+ frames. The planner received the object late and executed a hard brake with
190
+ residual collision risk. Contributing factors: confidence threshold raised
191
+ globally two releases earlier to suppress rain clutter false positives —
192
+ exactly the trade REQ-WX-01 prohibits. Fix: rain-conditioned fusion
193
+ weighting and restoration of threshold, plus targeted rain-night pedestrian
194
+ data collection.""",
195
+ },
196
+ {
197
+ "doc_id": "SFS-EVAL-005",
198
+ "document": "Evaluation Policies for Safety-Relevant Metrics",
199
+ "source": "sensorflow-internal",
200
+ "version": "1.2",
201
+ "jurisdiction": "internal",
202
+ "doc_type": "evaluation_policy",
203
+ "effective_date": "2026-02-05",
204
+ "synthetic": "true",
205
+ "label": SYNTH,
206
+ "text": f"""[{SYNTH}] Evaluation Policies for Safety-Relevant Metrics v1.2.
207
+
208
+ ## Safety-critical recall definition
209
+ [{SYNTH}] EVAL-SCR-01: Safety-critical recall (SCR) is recall computed only
210
+ over ground-truth objects whose criticality context meets the policy
211
+ criteria: object inside 1.5x stopping distance, closing relative velocity,
212
+ and VRU class or collision-relevant mass. SCR is reported with its
213
+ denominator; an SCR delta without a criticality-context denominator is not
214
+ admissible launch evidence.
215
+
216
+ ## Asymmetric error costs
217
+ [{SYNTH}] EVAL-COST-02: False negatives and false positives carry
218
+ asymmetric, CONTEXT-DEPENDENT costs. A missed distant non-closing object can
219
+ be BENIGN while a phantom hard brake with a tailgater can be CRITICAL. Cost
220
+ is computed from context (class, distance versus stopping distance, relative
221
+ motion, remaining reaction time, intervention magnitude), never from the
222
+ error type alone.
223
+
224
+ ## Statistical significance
225
+ [{SYNTH}] EVAL-SIG-03: Metric deltas feeding launch decisions must carry
226
+ anytime-valid confidence sequences (the platform's seqeval machinery) or an
227
+ explicit NOT_SIGNIFICANT marker. Point deltas without uncertainty are
228
+ inadmissible.""",
229
+ },
230
+ {
231
+ "doc_id": "SOTIF-CONCEPTS-21448",
232
+ "document": "SOTIF (ISO 21448) Concept Summary",
233
+ "source": "public-standard-paraphrase",
234
+ "version": "concept-summary-1.0",
235
+ "jurisdiction": "international",
236
+ "doc_type": "standard_concept_paraphrase",
237
+ "effective_date": "2022-06-01",
238
+ "synthetic": "false",
239
+ "label": "PARAPHRASE_NOT_STANDARD_TEXT",
240
+ "text": """[PARAPHRASE_NOT_STANDARD_TEXT] This is an honest concept-level
241
+ paraphrase of ideas associated with ISO 21448 (Safety of the Intended
242
+ Functionality, SOTIF). It contains NO standard text and NO clause numbers
243
+ and must never be cited as the standard itself.
244
+
245
+ ## Functional insufficiency
246
+ [PARAPHRASE_NOT_STANDARD_TEXT] SOTIF addresses hazards that arise not from
247
+ component faults but from performance limitations of the intended
248
+ functionality — for example a perception stack whose sensor physics or
249
+ training distribution is insufficient for a scenario (heavy rain, unusual
250
+ object appearance). A missed pedestrian in rain with no hardware fault is a
251
+ canonical SOTIF-style functional insufficiency, not a malfunction.
252
+
253
+ ## Triggering conditions and scenario space
254
+ [PARAPHRASE_NOT_STANDARD_TEXT] The concept framework partitions scenarios
255
+ into known-safe, known-hazardous, and unknown-hazardous. Engineering effort
256
+ aims to discover unknown-hazardous scenarios (triggering conditions such as
257
+ specific weather, lighting, or object configurations) and move them into the
258
+ known space, then reduce their residual risk through requirements, data,
259
+ or ODD restriction.
260
+
261
+ ## Verification and validation implication
262
+ [PARAPHRASE_NOT_STANDARD_TEXT] Retrospective analysis of field failures is
263
+ one of the accepted mechanisms for discovering triggering conditions.
264
+ Evidence from such analysis feeds requirement updates and validation
265
+ targets; residual risk acceptance must be argued, not assumed.""",
266
+ },
267
+ ]
268
+
269
+
270
+ def corpus_metadata(doc: Dict) -> Dict[str, str]:
271
+ return {
272
+ "doc_id": doc["doc_id"],
273
+ "document": doc["document"],
274
+ "source": doc["source"],
275
+ "version": doc["version"],
276
+ "jurisdiction": doc["jurisdiction"],
277
+ "doc_type": doc["doc_type"],
278
+ "effective_date": doc["effective_date"],
279
+ "synthetic": doc["synthetic"],
280
+ "label": doc["label"],
281
+ }
sensorflow/retro/rag/embedder.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedding layer with an honest, documented tradeoff.
2
+
3
+ Preferred path: chromadb's bundled embedder (all-MiniLM-L6-v2 via ONNX) when
4
+ chromadb is installed — real semantic embeddings.
5
+
6
+ Fallback path (always available): a deterministic hashed TF vector embedder.
7
+ Tradeoff: the hashing embedder is purely LEXICAL — it captures word overlap
8
+ (with sublinear term-frequency weighting and stable feature hashing) but has
9
+ zero semantic generalization ("pedestrian" and "person on foot" do not
10
+ match). For this package's seed corpus, which uses consistent engineering
11
+ vocabulary, lexical retrieval is adequate and — critically — fully
12
+ deterministic and dependency-free, which the test suite relies on.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import math
19
+ import re
20
+ from typing import List
21
+
22
+ import numpy as np
23
+
24
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
25
+
26
+ # Common words that would otherwise dominate hashed-TF cosine similarity.
27
+ _STOPWORDS = frozenset(
28
+ "a an and are as at be by for from has have in is it its of on or that the "
29
+ "this to was were will with within must shall should not no".split())
30
+
31
+
32
+ class HashedTfEmbedder:
33
+ """Deterministic hashed term-frequency embedder (numpy only)."""
34
+
35
+ name = "hashed-tf-fallback"
36
+
37
+ def __init__(self, dim: int = 1024):
38
+ self.dim = dim
39
+
40
+ def _bucket(self, token: str) -> int:
41
+ return int.from_bytes(hashlib.md5(token.encode()).digest()[:4], "little") % self.dim
42
+
43
+ def embed(self, texts: List[str]) -> np.ndarray:
44
+ out = np.zeros((len(texts), self.dim), dtype=np.float64)
45
+ for i, text in enumerate(texts):
46
+ for tok in _TOKEN_RE.findall(text.lower()):
47
+ if tok in _STOPWORDS:
48
+ continue
49
+ out[i, self._bucket(tok)] += 1.0
50
+ # sublinear tf then L2 normalize
51
+ nz = out[i] > 0
52
+ out[i, nz] = 1.0 + np.log(out[i, nz])
53
+ norm = math.sqrt(float(np.dot(out[i], out[i])))
54
+ if norm > 0:
55
+ out[i] /= norm
56
+ return out
57
+
58
+
59
+ def chromadb_available() -> bool:
60
+ try:
61
+ import chromadb # noqa: F401
62
+ return True
63
+ except ImportError:
64
+ return False
sensorflow/retro/rag/evalharness.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrieval evaluation harness: query set with expected-source assertions.
2
+
3
+ precision@k here means: fraction of queries whose expected doc_id appears in
4
+ the top-k results (a.k.a. hit-rate@k / recall@k over one relevant document
5
+ per query — reported explicitly so nobody mistakes it for a graded metric).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Optional
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from sensorflow.retro.rag.retriever import SafetyCaseIndex, get_index
15
+
16
+ # Each query names the doc that a correct retriever must surface.
17
+ EVAL_QUERIES: List[Dict[str, str]] = [
18
+ {"query": "pedestrian missed inside stopping distance recall requirement",
19
+ "expected_doc_id": "SFS-SAFE-001"},
20
+ {"query": "phantom braking limits for plastic bag road debris misclassification",
21
+ "expected_doc_id": "SFS-SAFE-001"},
22
+ {"query": "launch gate CRITICAL severity unresolved scorecard PASS blocked",
23
+ "expected_doc_id": "SFS-LAUNCH-004"},
24
+ {"query": "INSUFFICIENT_EVIDENCE can never become PASS",
25
+ "expected_doc_id": "SFS-LAUNCH-004"},
26
+ {"query": "rain degradation pedestrian recall confidence threshold raising prohibited",
27
+ "expected_doc_id": "SFS-PERC-002"},
28
+ {"query": "confidence calibration empirical precision rolling window",
29
+ "expected_doc_id": "SFS-PERC-002"},
30
+ {"query": "urban ODD posted speed crosswalk envelope",
31
+ "expected_doc_id": "SFS-ODD-003"},
32
+ {"query": "heavy rain outside ODD minimal-risk maneuver",
33
+ "expected_doc_id": "SFS-ODD-003"},
34
+ {"query": "historical retrospective phantom brake mylar balloon deformable clutter",
35
+ "expected_doc_id": "SFS-RETRO-HIST-01"},
36
+ {"query": "pedestrian dark clothing rain night fusion confidence below tracking threshold",
37
+ "expected_doc_id": "SFS-RETRO-HIST-01"},
38
+ {"query": "safety-critical recall SCR denominator criticality context",
39
+ "expected_doc_id": "SFS-EVAL-005"},
40
+ {"query": "asymmetric false negative false positive cost context dependent",
41
+ "expected_doc_id": "SFS-EVAL-005"},
42
+ {"query": "SOTIF functional insufficiency performance limitation intended functionality",
43
+ "expected_doc_id": "SOTIF-CONCEPTS-21448"},
44
+ {"query": "unknown hazardous scenarios triggering conditions discovery",
45
+ "expected_doc_id": "SOTIF-CONCEPTS-21448"},
46
+ ]
47
+
48
+
49
+ class QueryResult(BaseModel):
50
+ query: str
51
+ expected_doc_id: str
52
+ hit_at_k: bool
53
+ top_doc_ids: List[str]
54
+ top_score: float
55
+
56
+
57
+ class RetrievalEvalReport(BaseModel):
58
+ k: int
59
+ num_queries: int
60
+ num_hits: int
61
+ precision_at_k: float # hit-rate@k with one relevant doc per query
62
+ store_backend: str
63
+ results: List[QueryResult]
64
+
65
+
66
+ def run_retrieval_eval(index: Optional[SafetyCaseIndex] = None,
67
+ k: int = 4) -> RetrievalEvalReport:
68
+ index = index or get_index()
69
+ results: List[QueryResult] = []
70
+ hits = 0
71
+ for item in EVAL_QUERIES:
72
+ found = index.search(item["query"], k=k)
73
+ doc_ids = [f.doc_id for f in found]
74
+ hit = item["expected_doc_id"] in doc_ids
75
+ hits += int(hit)
76
+ results.append(QueryResult(
77
+ query=item["query"], expected_doc_id=item["expected_doc_id"],
78
+ hit_at_k=hit, top_doc_ids=doc_ids,
79
+ top_score=found[0].relevance_score if found else 0.0))
80
+ return RetrievalEvalReport(
81
+ k=k, num_queries=len(EVAL_QUERIES), num_hits=hits,
82
+ precision_at_k=round(hits / len(EVAL_QUERIES), 4),
83
+ store_backend=index.backend_name, results=results)
sensorflow/retro/rag/retriever.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retriever over the safety-case index.
2
+
3
+ Every hit is returned as a RetrievedStandard carrying
4
+ {source, document, version, section, retrieved_text, relevance_score} —
5
+ ALWAYS, with no optional escape hatch. The scorecard layer refuses citations
6
+ that are not instances of this model (the no-citation-without-retrieval
7
+ rule, enforced and tested).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from typing import List, Optional
14
+
15
+ from sensorflow.retro.rag.chunking import chunk_document
16
+ from sensorflow.retro.rag.corpus import SEED_DOCUMENTS, corpus_metadata
17
+ from sensorflow.retro.rag.store import VectorStore, build_store
18
+ from sensorflow.retro.scorecard import RetrievedStandard
19
+
20
+
21
+ class SafetyCaseIndex:
22
+ def __init__(self, store: Optional[VectorStore] = None,
23
+ chunk_size: int = 160, overlap: int = 40):
24
+ self.store = store or build_store()
25
+ self.chunk_size = chunk_size
26
+ self.overlap = overlap
27
+ self._loaded_docs = 0
28
+ self._load_seed_corpus()
29
+
30
+ @property
31
+ def backend_name(self) -> str:
32
+ return self.store.backend_name
33
+
34
+ def _load_seed_corpus(self) -> None:
35
+ ids: List[str] = []
36
+ texts: List[str] = []
37
+ metas: List[dict] = []
38
+ for doc in SEED_DOCUMENTS:
39
+ base_meta = corpus_metadata(doc)
40
+ for chunk in chunk_document(doc["doc_id"], doc["text"], base_meta,
41
+ self.chunk_size, self.overlap):
42
+ ids.append(chunk.chunk_id)
43
+ texts.append(chunk.text)
44
+ metas.append(chunk.metadata)
45
+ self.store.add(ids, texts, metas)
46
+ self._loaded_docs = len(SEED_DOCUMENTS)
47
+
48
+ def search(self, query: str, k: int = 4) -> List[RetrievedStandard]:
49
+ hits = self.store.query(query, k=k)
50
+ out: List[RetrievedStandard] = []
51
+ for h in hits:
52
+ meta = h.metadata
53
+ out.append(RetrievedStandard(
54
+ source=meta.get("source", "unknown"),
55
+ document=meta.get("document", meta.get("doc_id", "unknown")),
56
+ version=meta.get("version", "unknown"),
57
+ section=meta.get("section", "unknown"),
58
+ retrieved_text=h.text,
59
+ relevance_score=round(h.relevance_score, 4),
60
+ doc_id=meta.get("doc_id", "unknown"),
61
+ doc_type=meta.get("doc_type", "unknown"),
62
+ jurisdiction=meta.get("jurisdiction", "unknown"),
63
+ effective_date=meta.get("effective_date", "unknown"),
64
+ synthetic=meta.get("synthetic", "true") == "true",
65
+ label=meta.get("label", ""),
66
+ chunk_id=h.chunk_id,
67
+ ))
68
+ return out
69
+
70
+
71
+ _INDEX: Optional[SafetyCaseIndex] = None
72
+ _INDEX_LOCK = threading.Lock()
73
+
74
+
75
+ def get_index() -> SafetyCaseIndex:
76
+ """Process-wide lazily-built index (seed corpus is static)."""
77
+ global _INDEX
78
+ with _INDEX_LOCK:
79
+ if _INDEX is None:
80
+ _INDEX = SafetyCaseIndex()
81
+ return _INDEX
sensorflow/retro/rag/store.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vector store with two interchangeable implementations.
2
+
3
+ - ChromaStore: chromadb (its bundled embedder), when installed.
4
+ - NumpyCosineStore: deterministic hashed-TF embeddings + cosine similarity.
5
+
6
+ Both expose the exact same interface: add(ids, texts, metadatas) and
7
+ query(text, k) -> List[StoreHit]. build_store() picks chroma when available
8
+ unless RETRO_FORCE_FALLBACK_STORE=1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Dict, List, Optional, Protocol
15
+
16
+ import numpy as np
17
+ from pydantic import BaseModel
18
+
19
+ from sensorflow.retro.rag.embedder import HashedTfEmbedder, chromadb_available
20
+
21
+
22
+ class StoreHit(BaseModel):
23
+ chunk_id: str
24
+ text: str
25
+ metadata: Dict[str, str]
26
+ relevance_score: float # cosine similarity in [0, 1] (clipped)
27
+
28
+
29
+ class VectorStore(Protocol):
30
+ backend_name: str
31
+
32
+ def add(self, ids: List[str], texts: List[str],
33
+ metadatas: List[Dict[str, str]]) -> None: ...
34
+
35
+ def query(self, text: str, k: int = 5) -> List[StoreHit]: ...
36
+
37
+
38
+ class NumpyCosineStore:
39
+ """Deterministic fallback store: hashed-TF embeddings, cosine ranking."""
40
+
41
+ backend_name = "numpy-cosine-fallback"
42
+
43
+ def __init__(self, embedder: Optional[HashedTfEmbedder] = None):
44
+ self.embedder = embedder or HashedTfEmbedder()
45
+ self._ids: List[str] = []
46
+ self._texts: List[str] = []
47
+ self._metas: List[Dict[str, str]] = []
48
+ self._matrix: Optional[np.ndarray] = None
49
+
50
+ def add(self, ids: List[str], texts: List[str],
51
+ metadatas: List[Dict[str, str]]) -> None:
52
+ if not (len(ids) == len(texts) == len(metadatas)):
53
+ raise ValueError("ids/texts/metadatas length mismatch")
54
+ self._ids.extend(ids)
55
+ self._texts.extend(texts)
56
+ self._metas.extend(metadatas)
57
+ embs = self.embedder.embed(texts)
58
+ self._matrix = embs if self._matrix is None else np.vstack([self._matrix, embs])
59
+
60
+ def query(self, text: str, k: int = 5) -> List[StoreHit]:
61
+ if self._matrix is None or not self._ids:
62
+ return []
63
+ q = self.embedder.embed([text])[0]
64
+ sims = self._matrix @ q # rows are L2-normalized -> dot = cosine
65
+ order = np.argsort(-sims)[:k]
66
+ return [StoreHit(chunk_id=self._ids[i], text=self._texts[i],
67
+ metadata=self._metas[i],
68
+ relevance_score=float(np.clip(sims[i], 0.0, 1.0)))
69
+ for i in order]
70
+
71
+
72
+ class ChromaStore:
73
+ """chromadb-backed store using its default bundled embedder."""
74
+
75
+ backend_name = "chromadb"
76
+
77
+ def __init__(self, collection_name: str = "retro_safety_case"):
78
+ import chromadb
79
+ self._client = chromadb.EphemeralClient()
80
+ try:
81
+ self._client.delete_collection(collection_name)
82
+ except Exception:
83
+ pass
84
+ self._collection = self._client.create_collection(
85
+ collection_name, metadata={"hnsw:space": "cosine"})
86
+
87
+ def add(self, ids: List[str], texts: List[str],
88
+ metadatas: List[Dict[str, str]]) -> None:
89
+ self._collection.add(ids=ids, documents=texts, metadatas=metadatas)
90
+
91
+ def query(self, text: str, k: int = 5) -> List[StoreHit]:
92
+ res = self._collection.query(query_texts=[text], n_results=k)
93
+ hits: List[StoreHit] = []
94
+ for i, cid in enumerate(res["ids"][0]):
95
+ distance = float(res["distances"][0][i]) # cosine distance
96
+ hits.append(StoreHit(
97
+ chunk_id=cid,
98
+ text=res["documents"][0][i],
99
+ metadata={k2: str(v) for k2, v in (res["metadatas"][0][i] or {}).items()},
100
+ relevance_score=float(np.clip(1.0 - distance, 0.0, 1.0))))
101
+ return hits
102
+
103
+
104
+ def build_store() -> VectorStore:
105
+ if chromadb_available() and os.environ.get("RETRO_FORCE_FALLBACK_STORE") != "1":
106
+ try:
107
+ return ChromaStore()
108
+ except Exception:
109
+ # chromadb installed but not functional (e.g. onnxruntime issue):
110
+ # fall back rather than fail — the interface is identical.
111
+ pass
112
+ return NumpyCosineStore()
sensorflow/retro/requirements-retro.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Retro (Agentic Retrospective Safety Analyzer) dependencies.
2
+ #
3
+ # Hard requirements — already part of the platform environment:
4
+ pydantic>=2.6
5
+ httpx>=0.27
6
+ numpy>=1.26
7
+ fastapi>=0.110
8
+
9
+ # Optional — the package degrades gracefully when these are absent:
10
+ # chromadb vector store + embedder for the safety-case RAG.
11
+ # Fallback: deterministic hashed TF-IDF embedder + numpy
12
+ # cosine store with the same interface (rag/embedder.py,
13
+ # rag/store.py).
14
+ # chromadb>=0.5
15
+ #
16
+ # pydantic-ai agent orchestration. Fallback: internal typed tool loop
17
+ # (agent/orchestrator.py) with the same contracts.
18
+ # pydantic-ai-slim>=0.0.30
19
+ #
20
+ # mcp exposes the tool registry as a real MCP server (bonus).
21
+ # Fallback: the audited registry itself is the MCP-style
22
+ # boundary (tools/registry.py).
23
+ # mcp>=1.0
24
+
25
+ # GPU-host only (NOT installable/runnable on this macOS dev machine):
26
+ # vllm>=0.6 # CUDA/ROCm Linux hosts; see inference/vllm_server/
27
+ # torch # install the wheel matching your GPU stack
sensorflow/retro/scorecard.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evidence hierarchy and the strictly-typed RetrospectiveScorecard.
2
+
3
+ Tier semantics (enforced in types, rendered distinctly):
4
+ TIER1_OBSERVED FACT — read directly from the failure artifact.
5
+ TIER2_DERIVED DERIVED FACT — deterministically computed from facts.
6
+ TIER3_RETRIEVED RETRIEVED REQUIREMENT — backed by a retrieval hit.
7
+ TIER4_AI_HYPOTHESIS AI HYPOTHESIS — inference, never presented as fact.
8
+ The SAFETY DETERMINATION (severity + launch recommendation) is produced only
9
+ by the deterministic policy engine (sensorflow.retro.policy).
10
+
11
+ The no-citation-without-retrieval rule lives here: RetrievedStandard has NO
12
+ optional escape hatch — a citation cannot be constructed without the actual
13
+ retrieved text, its relevance score, and its source metadata.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime, timezone
19
+ from enum import Enum
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from pydantic import BaseModel, Field, field_validator
23
+
24
+ UNKNOWN = "UNKNOWN"
25
+ INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"
26
+
27
+
28
+ class EvidenceTier(str, Enum):
29
+ OBSERVED = "TIER1_OBSERVED"
30
+ DERIVED = "TIER2_DERIVED"
31
+ RETRIEVED = "TIER3_RETRIEVED"
32
+ AI_HYPOTHESIS = "TIER4_AI_HYPOTHESIS"
33
+
34
+
35
+ TIER_RENDER_NAMES = {
36
+ EvidenceTier.OBSERVED: "FACT",
37
+ EvidenceTier.DERIVED: "DERIVED FACT",
38
+ EvidenceTier.RETRIEVED: "RETRIEVED REQUIREMENT",
39
+ EvidenceTier.AI_HYPOTHESIS: "AI HYPOTHESIS",
40
+ }
41
+
42
+
43
+ class Severity(str, Enum):
44
+ BENIGN = "BENIGN"
45
+ DISRUPTIVE = "DISRUPTIVE"
46
+ CRITICAL = "CRITICAL"
47
+ FATAL = "FATAL"
48
+
49
+
50
+ class LaunchRecommendation(str, Enum):
51
+ PASS = "PASS"
52
+ CONDITIONAL_PASS = "CONDITIONAL_PASS"
53
+ FAIL = "FAIL"
54
+ INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"
55
+
56
+
57
+ class EvidenceItem(BaseModel):
58
+ tier: EvidenceTier
59
+ key: str # machine key, e.g. "ego_speed_mps"
60
+ statement: str # human-readable statement
61
+ value: Optional[str] = None # string form; UNKNOWN if absent
62
+ provenance: str # where this came from (field/tool/doc/agent)
63
+
64
+ @field_validator("statement")
65
+ @classmethod
66
+ def _nonempty(cls, v: str) -> str:
67
+ if not v.strip():
68
+ raise ValueError("evidence statement must be non-empty")
69
+ return v
70
+
71
+
72
+ class RetrievedStandard(BaseModel):
73
+ """A citation. Constructible ONLY with real retrieval output."""
74
+ source: str
75
+ document: str
76
+ version: str
77
+ section: str
78
+ retrieved_text: str = Field(min_length=20)
79
+ relevance_score: float = Field(ge=0.0, le=1.0)
80
+ doc_id: str
81
+ doc_type: str
82
+ jurisdiction: str
83
+ effective_date: str
84
+ synthetic: bool
85
+ label: str # SYNTHETIC_EXAMPLE... or PARAPHRASE...
86
+ chunk_id: str
87
+
88
+
89
+ class RootCauseHypothesis(BaseModel):
90
+ hypothesis: str
91
+ confidence: float = Field(ge=0.0, le=1.0)
92
+ supporting_evidence_keys: List[str] = Field(default_factory=list)
93
+ missing_evidence: List[str] = Field(default_factory=list)
94
+ tier: EvidenceTier = EvidenceTier.AI_HYPOTHESIS
95
+
96
+ @field_validator("tier")
97
+ @classmethod
98
+ def _hypothesis_is_never_fact(cls, v: EvidenceTier) -> EvidenceTier:
99
+ if v != EvidenceTier.AI_HYPOTHESIS:
100
+ raise ValueError("root-cause hypotheses are always TIER4_AI_HYPOTHESIS")
101
+ return v
102
+
103
+
104
+ class StatSignificance(BaseModel):
105
+ method: str # e.g. "seqeval.PairedSequentialTest"
106
+ significant: Optional[bool] # None => not evaluable from this artifact
107
+ detail: str
108
+
109
+
110
+ class UncertaintyReport(BaseModel):
111
+ missing_fields: List[str] = Field(default_factory=list)
112
+ unknown_metrics: List[str] = Field(default_factory=list)
113
+ notes: List[str] = Field(default_factory=list)
114
+
115
+ @property
116
+ def has_gaps(self) -> bool:
117
+ return bool(self.missing_fields or self.unknown_metrics)
118
+
119
+
120
+ class RetrospectiveScorecard(BaseModel):
121
+ # identity / provenance
122
+ evaluation_id: str
123
+ created_at: str = Field(
124
+ default_factory=lambda: datetime.now(timezone.utc).isoformat())
125
+ policy_version: str
126
+ agent_version: str
127
+ backend_used: str
128
+
129
+ # classification
130
+ failure_type: str # FALSE_POSITIVE | FALSE_NEGATIVE | ...
131
+ severity: Severity # POLICY-decided severity (authoritative)
132
+ ai_proposed_severity: Optional[Severity] = None
133
+ severity_divergence: bool = False
134
+ safety_critical_recall_impact: Optional[float] = None
135
+ scr_impact_detail: str = ""
136
+ behavioral_consequence: str
137
+ launch_recommendation: LaunchRecommendation
138
+ launch_rationale: List[str] = Field(default_factory=list)
139
+
140
+ # context
141
+ baseline_model: Optional[str] = None
142
+ candidate_model: Optional[str] = None
143
+ scenario: Dict[str, Any] = Field(default_factory=dict)
144
+ object_class: Optional[str] = None
145
+ ground_truth: Optional[Dict[str, Any]] = None
146
+ prediction: Optional[Dict[str, Any]] = None
147
+ confidence: Optional[float] = None
148
+
149
+ # kinematics / behavior (None => UNKNOWN, listed in uncertainty)
150
+ ego_speed_mps: Optional[float] = None
151
+ distance_to_object_m: Optional[float] = None
152
+ relative_velocity_mps: Optional[float] = None
153
+ stopping_distance_m: Optional[float] = None
154
+ ttc_s: Optional[float] = None
155
+ ttc_validity: List[str] = Field(default_factory=list)
156
+ planner_response: Optional[Dict[str, Any]] = None
157
+ disengagement_probability: Optional[float] = None
158
+
159
+ # statistics
160
+ metric_delta: Optional[Dict[str, float]] = None
161
+ statistical_significance: Optional[StatSignificance] = None
162
+ distribution_shift: Optional[Dict[str, Any]] = None
163
+
164
+ # evidence chain
165
+ root_cause_hypotheses: List[RootCauseHypothesis] = Field(default_factory=list)
166
+ evidence: List[EvidenceItem] = Field(min_length=1)
167
+ retrieved_standards: List[RetrievedStandard] = Field(default_factory=list)
168
+ uncertainty: UncertaintyReport
169
+ human_review_required: bool
170
+ human_review_reasons: List[str] = Field(default_factory=list)
171
+
172
+ def evidence_by_tier(self, tier: EvidenceTier) -> List[EvidenceItem]:
173
+ return [e for e in self.evidence if e.tier == tier]
174
+
175
+ # ------------------------------------------------------------- rendering
176
+
177
+ def render_markdown(self) -> str:
178
+ lines: List[str] = [
179
+ f"# Retrospective Scorecard — {self.evaluation_id}",
180
+ "",
181
+ f"- **Failure type:** {self.failure_type}",
182
+ f"- **Severity (policy determination):** {self.severity.value}"
183
+ + (f" _(AI proposed {self.ai_proposed_severity.value}; divergence "
184
+ f"flagged for human review)_" if self.severity_divergence
185
+ and self.ai_proposed_severity else ""),
186
+ f"- **Launch recommendation:** {self.launch_recommendation.value}",
187
+ f"- **Human review required:** "
188
+ f"{'YES — ' + '; '.join(self.human_review_reasons) if self.human_review_required else 'no'}",
189
+ f"- **Models:** baseline={self.baseline_model or UNKNOWN} "
190
+ f"candidate={self.candidate_model or UNKNOWN}",
191
+ f"- **Policy/agent:** {self.policy_version} / {self.agent_version} "
192
+ f"(backend: {self.backend_used})",
193
+ "",
194
+ "## Evidence chain (tiered)",
195
+ ]
196
+ for tier in EvidenceTier:
197
+ items = self.evidence_by_tier(tier)
198
+ if not items:
199
+ continue
200
+ lines.append(f"### {TIER_RENDER_NAMES[tier]} ({tier.value})")
201
+ for e in items:
202
+ val = f" = `{e.value}`" if e.value is not None else ""
203
+ lines.append(f"- **{e.key}**{val}: {e.statement} \n"
204
+ f" _provenance: {e.provenance}_")
205
+ lines.append("")
206
+
207
+ lines.append("## RETRIEVED REQUIREMENTS (citations)")
208
+ if not self.retrieved_standards:
209
+ lines.append("_none retrieved — no requirement is cited_")
210
+ for s in self.retrieved_standards:
211
+ lines.append(
212
+ f"- **[{s.label}]** {s.document} v{s.version} — §{s.section} "
213
+ f"(source: {s.source}, relevance {s.relevance_score:.2f}) \n"
214
+ f" > {s.retrieved_text[:280]}{'…' if len(s.retrieved_text) > 280 else ''}")
215
+ lines.append("")
216
+
217
+ lines.append("## AI HYPOTHESES (inference — never fact)")
218
+ for h in self.root_cause_hypotheses:
219
+ lines.append(f"- ({h.confidence:.0%} confidence) {h.hypothesis} \n"
220
+ f" _supported by: {', '.join(h.supporting_evidence_keys) or 'n/a'};"
221
+ f" missing: {', '.join(h.missing_evidence) or 'none'}_")
222
+ lines.append("")
223
+
224
+ lines.append("## SAFETY DETERMINATION (deterministic policy)")
225
+ lines.append(f"- Severity: **{self.severity.value}**")
226
+ lines.append(f"- Launch: **{self.launch_recommendation.value}**")
227
+ for r in self.launch_rationale:
228
+ lines.append(f" - {r}")
229
+ lines.append("")
230
+
231
+ if self.uncertainty.has_gaps:
232
+ lines.append("## UNCERTAINTY / MISSING EVIDENCE")
233
+ for f in self.uncertainty.missing_fields:
234
+ lines.append(f"- missing field: `{f}` -> {UNKNOWN}")
235
+ for m in self.uncertainty.unknown_metrics:
236
+ lines.append(f"- metric not computable: `{m}` -> {UNKNOWN}")
237
+ for n in self.uncertainty.notes:
238
+ lines.append(f"- {n}")
239
+
240
+ return "\n".join(lines)
sensorflow/retro/store.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence for retro artifacts under runs/retro/.
2
+
3
+ runs/retro/analyses/<evaluation_id>.json scorecard + metadata
4
+ runs/retro/audit/<analysis_id>.jsonl tool-call audit trail
5
+ runs/retro/eval_cases/<case_id>.json cases created by the write tool
6
+ runs/retro/uploads/<name>.json uploaded logs (allowlisted reads)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ def root() -> Path:
17
+ """Resolved per call so tests can isolate via RETRO_RUNS_DIR."""
18
+ return Path(os.environ.get("RETRO_RUNS_DIR", "runs/retro"))
19
+
20
+
21
+ def _dir(name: str) -> Path:
22
+ d = root() / name
23
+ d.mkdir(parents=True, exist_ok=True)
24
+ return d
25
+
26
+
27
+ def analyses_dir() -> Path:
28
+ return _dir("analyses")
29
+
30
+
31
+ def audit_dir() -> Path:
32
+ return _dir("audit")
33
+
34
+
35
+ def eval_cases_dir() -> Path:
36
+ return _dir("eval_cases")
37
+
38
+
39
+ def uploads_dir() -> Path:
40
+ return _dir("uploads")
41
+
42
+
43
+ def save_analysis(evaluation_id: str, payload: Dict) -> Path:
44
+ path = analyses_dir() / f"{evaluation_id}.json"
45
+ path.write_text(json.dumps(payload, indent=2))
46
+ return path
47
+
48
+
49
+ def load_analysis(evaluation_id: str) -> Optional[Dict]:
50
+ path = analyses_dir() / f"{evaluation_id}.json"
51
+ if not path.exists():
52
+ return None
53
+ return json.loads(path.read_text())
54
+
55
+
56
+ def list_analyses() -> List[Dict]:
57
+ out = []
58
+ for p in sorted(analyses_dir().glob("*.json")):
59
+ try:
60
+ data = json.loads(p.read_text())
61
+ sc = data.get("scorecard", {})
62
+ out.append({
63
+ "evaluation_id": sc.get("evaluation_id", p.stem),
64
+ "created_at": sc.get("created_at"),
65
+ "failure_type": sc.get("failure_type"),
66
+ "severity": sc.get("severity"),
67
+ "launch_recommendation": sc.get("launch_recommendation"),
68
+ "backend_used": sc.get("backend_used"),
69
+ "human_review_required": sc.get("human_review_required"),
70
+ })
71
+ except (json.JSONDecodeError, OSError):
72
+ continue
73
+ return out
74
+
75
+
76
+ def append_audit(analysis_id: str, record: Dict) -> None:
77
+ path = audit_dir() / f"{analysis_id}.jsonl"
78
+ with path.open("a") as f:
79
+ f.write(json.dumps(record) + "\n")
80
+
81
+
82
+ def read_audit(analysis_id: str) -> List[Dict]:
83
+ path = audit_dir() / f"{analysis_id}.jsonl"
84
+ if not path.exists():
85
+ return []
86
+ return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
sensorflow/retro/tools/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """MCP-style audited tool registry and the built-in analysis tools."""
sensorflow/retro/tools/builtin.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Built-in analysis tools registered on the audited registry.
2
+
3
+ All tools are read-only except CreateEvaluationCaseTool, which requires an
4
+ explicit policy_authorization flag per call (enforced by the registry).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import numpy as np
17
+ from pydantic import BaseModel, Field
18
+
19
+ from sensorflow.retro import metrics, store
20
+ from sensorflow.retro.rag.embedder import HashedTfEmbedder
21
+ from sensorflow.retro.rag.retriever import get_index
22
+ from sensorflow.retro.tools.registry import ToolRegistry
23
+
24
+ FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures"
25
+
26
+ # Dotted paths the analyzer needs; absent ones become UNKNOWN, never guessed.
27
+ REQUIRED_FIELDS = [
28
+ "ego.speed_mps",
29
+ "ground_truth.class",
30
+ "ground_truth.distance_m",
31
+ "ground_truth.relative_velocity_mps",
32
+ "planner_response",
33
+ "counterfactual_planner_response",
34
+ "timing.detection_latency_ms",
35
+ "timing.planner_latency_ms",
36
+ "scenario.weather",
37
+ "models.baseline",
38
+ "models.candidate",
39
+ "collision",
40
+ "traffic_context.following_vehicle",
41
+ ]
42
+
43
+
44
+ def _dotted_get(obj: Dict[str, Any], path: str) -> Any:
45
+ cur: Any = obj
46
+ for part in path.split("."):
47
+ if not isinstance(cur, dict) or part not in cur or cur[part] is None:
48
+ return None
49
+ cur = cur[part]
50
+ return cur
51
+
52
+
53
+ # ------------------------------------------------------------- LogReaderTool
54
+
55
+ class LogReaderInput(BaseModel):
56
+ fixture_id: Optional[str] = Field(
57
+ None, description="fixture name (stem) under sensorflow/retro/fixtures")
58
+ path: Optional[str] = Field(
59
+ None, description="path under the approved allowlist "
60
+ "(runs/retro/uploads or the fixtures dir)")
61
+
62
+
63
+ class LogReaderOutput(BaseModel):
64
+ source_path: str
65
+ log: Dict[str, Any]
66
+ present_fields: List[str]
67
+ missing_fields: List[str]
68
+
69
+
70
+ def _allowed_roots() -> List[Path]:
71
+ return [FIXTURES_DIR.resolve(), store.uploads_dir().resolve()]
72
+
73
+
74
+ def log_reader(inp: LogReaderInput) -> LogReaderOutput:
75
+ if not inp.fixture_id and not inp.path:
76
+ raise ValueError("provide fixture_id or path")
77
+ if inp.fixture_id:
78
+ if not re.fullmatch(r"[A-Za-z0-9_\-]+", inp.fixture_id):
79
+ raise PermissionError(f"invalid fixture id {inp.fixture_id!r}")
80
+ target = (FIXTURES_DIR / f"{inp.fixture_id}.json").resolve()
81
+ else:
82
+ target = Path(inp.path).resolve()
83
+ if not any(str(target).startswith(str(root) + "/") or target.parent == root
84
+ for root in _allowed_roots()):
85
+ raise PermissionError(
86
+ f"path {target} is outside the approved artifact allowlist "
87
+ f"({[str(r) for r in _allowed_roots()]})")
88
+ if not target.exists():
89
+ raise FileNotFoundError(f"artifact not found: {target}")
90
+ log = json.loads(target.read_text())
91
+ present, missing = [], []
92
+ for fieldpath in REQUIRED_FIELDS:
93
+ (present if _dotted_get(log, fieldpath) is not None else missing).append(fieldpath)
94
+ return LogReaderOutput(source_path=str(target), log=log,
95
+ present_fields=present, missing_fields=missing)
96
+
97
+
98
+ # ------------------------------------------------------ SafetyStandardRAGTool
99
+
100
+ class RAGSearchInput(BaseModel):
101
+ query: str = Field(min_length=3)
102
+ k: int = Field(4, ge=1, le=10)
103
+
104
+
105
+ class RAGSearchOutput(BaseModel):
106
+ store_backend: str
107
+ hits: List[Dict[str, Any]]
108
+
109
+
110
+ def rag_search(inp: RAGSearchInput) -> RAGSearchOutput:
111
+ index = get_index()
112
+ hits = index.search(inp.query, k=inp.k)
113
+ return RAGSearchOutput(store_backend=index.backend_name,
114
+ hits=[h.model_dump() for h in hits])
115
+
116
+
117
+ # ------------------------------------------------------- MetricCalculatorTool
118
+
119
+ class MetricCalcInput(BaseModel):
120
+ operation: str = Field(description="stopping_distance | ttc | scr_impact | "
121
+ "behavioral_impact")
122
+ params: Dict[str, Any] = Field(default_factory=dict)
123
+
124
+
125
+ class MetricCalcOutput(BaseModel):
126
+ operation: str
127
+ result: Dict[str, Any]
128
+
129
+
130
+ def metric_calculator(inp: MetricCalcInput) -> MetricCalcOutput:
131
+ ops = {
132
+ "stopping_distance": lambda p: metrics.stopping_distance(**p),
133
+ "ttc": lambda p: metrics.time_to_collision(**p),
134
+ "scr_impact": lambda p: metrics.scr_impact(p.get("evaluation_context")),
135
+ "behavioral_impact": lambda p: metrics.behavioral_impact(
136
+ p.get("observed"), p.get("counterfactual")),
137
+ }
138
+ if inp.operation not in ops:
139
+ raise ValueError(f"unknown operation {inp.operation!r}; "
140
+ f"expected one of {sorted(ops)}")
141
+ result = ops[inp.operation](inp.params)
142
+ return MetricCalcOutput(operation=inp.operation, result=result.model_dump())
143
+
144
+
145
+ # ------------------------------------------------ HistoricalFailureSearchTool
146
+
147
+ class HistoricalSearchInput(BaseModel):
148
+ query_text: str = Field(min_length=3)
149
+ k: int = Field(3, ge=1, le=10)
150
+ exclude_evaluation_id: Optional[str] = None
151
+
152
+
153
+ class HistoricalSearchOutput(BaseModel):
154
+ matches: List[Dict[str, Any]]
155
+ corpus_size: int
156
+
157
+
158
+ def historical_search(inp: HistoricalSearchInput) -> HistoricalSearchOutput:
159
+ """Similarity over previously analyzed failures (persisted scorecards)."""
160
+ embedder = HashedTfEmbedder()
161
+ docs, meta = [], []
162
+ for entry in store.list_analyses():
163
+ eid = entry.get("evaluation_id")
164
+ if not eid or eid == inp.exclude_evaluation_id:
165
+ continue
166
+ full = store.load_analysis(eid) or {}
167
+ sc = full.get("scorecard", {})
168
+ text = " ".join([
169
+ str(sc.get("failure_type", "")),
170
+ str(sc.get("behavioral_consequence", "")),
171
+ json.dumps(sc.get("scenario", {})),
172
+ " ".join(h.get("hypothesis", "") for h in sc.get("root_cause_hypotheses", [])),
173
+ ])
174
+ docs.append(text)
175
+ meta.append(entry)
176
+ if not docs:
177
+ return HistoricalSearchOutput(matches=[], corpus_size=0)
178
+ mat = embedder.embed(docs)
179
+ q = embedder.embed([inp.query_text])[0]
180
+ sims = mat @ q
181
+ order = np.argsort(-sims)[:inp.k]
182
+ matches = [{**meta[i], "similarity": round(float(np.clip(sims[i], 0, 1)), 4)}
183
+ for i in order]
184
+ return HistoricalSearchOutput(matches=matches, corpus_size=len(docs))
185
+
186
+
187
+ # -------------------------------------------------- DistributionAnalysisTool
188
+
189
+ class DistributionInput(BaseModel):
190
+ distribution_shift: Optional[Dict[str, Any]] = Field(
191
+ None, description="slice-level shift data from the eval artifact")
192
+ run_id: Optional[str] = Field(
193
+ None, description="megaeval run id — when provided and resolvable, the "
194
+ "analysis delegates to sensorflow.megaeval.analysis")
195
+
196
+
197
+ class DistributionOutput(BaseModel):
198
+ source: str
199
+ findings: List[str]
200
+ shift: Optional[Dict[str, Any]] = None
201
+
202
+
203
+ def distribution_analysis(inp: DistributionInput) -> DistributionOutput:
204
+ if inp.run_id:
205
+ # Delegate to the platform's megaeval statistics when a real run exists.
206
+ try:
207
+ from sensorflow.megaeval.analysis import distribution_shift
208
+ from sensorflow.megaeval.runs import get_run, get_store
209
+ run = get_run(get_store(), inp.run_id)
210
+ res = distribution_shift(get_store(), run)
211
+ return DistributionOutput(
212
+ source="sensorflow.megaeval.analysis.distribution_shift",
213
+ findings=[f"megaeval shift analysis for run {inp.run_id}"],
214
+ shift=res)
215
+ except Exception as exc:
216
+ return DistributionOutput(
217
+ source="megaeval-delegation-failed",
218
+ findings=[f"could not delegate to megaeval ({exc}); no "
219
+ "distribution claim is made"], shift=None)
220
+ ds = inp.distribution_shift
221
+ if not ds:
222
+ return DistributionOutput(
223
+ source="none",
224
+ findings=["no distribution data in artifact -> distribution shift "
225
+ "UNKNOWN"], shift=None)
226
+ findings = []
227
+ cand, base = ds.get("candidate_fn_rate", ds.get("candidate_fp_rate")), \
228
+ ds.get("baseline_fn_rate", ds.get("baseline_fp_rate"))
229
+ if cand is not None and base is not None and base > 0:
230
+ ratio = cand / base
231
+ findings.append(
232
+ f"slice '{ds.get('slice', 'unknown')}': error rate {base:.3f} -> "
233
+ f"{cand:.3f} ({ratio:.2f}x) per the evaluation artifact")
234
+ if ratio > 1.3:
235
+ findings.append("relative increase exceeds 1.3x: concentrated "
236
+ "regression in this slice rather than uniform noise")
237
+ else:
238
+ findings.append("artifact shift block lacks comparable rates -> UNKNOWN")
239
+ return DistributionOutput(source="artifact-provided slice rates",
240
+ findings=findings, shift=ds)
241
+
242
+
243
+ # ------------------------------------------------- CreateEvaluationCaseTool
244
+
245
+ class CreateCaseInput(BaseModel):
246
+ title: str = Field(min_length=5)
247
+ description: str = Field(min_length=10)
248
+ scenario_tags: List[str] = Field(default_factory=list)
249
+ source_evaluation_id: str
250
+
251
+
252
+ class CreateCaseOutput(BaseModel):
253
+ case_id: str
254
+ path: str
255
+ created_at: str
256
+
257
+
258
+ def create_evaluation_case(inp: CreateCaseInput) -> CreateCaseOutput:
259
+ """THE write tool. The registry refuses this call without an explicit
260
+ policy_authorization=True flag; every call is audited."""
261
+ case_id = f"case-{uuid.uuid4().hex[:10]}"
262
+ created = datetime.now(timezone.utc).isoformat()
263
+ payload = {"case_id": case_id, "created_at": created, **inp.model_dump()}
264
+ path = store.eval_cases_dir() / f"{case_id}.json"
265
+ path.write_text(json.dumps(payload, indent=2))
266
+ return CreateCaseOutput(case_id=case_id, path=str(path), created_at=created)
267
+
268
+
269
+ # --------------------------------------------------------------- registration
270
+
271
+ def build_registry(analysis_id: Optional[str] = None,
272
+ persist_audit: bool = True) -> ToolRegistry:
273
+ reg = ToolRegistry(analysis_id=analysis_id, persist_audit=persist_audit)
274
+ reg.register("log_reader", log_reader, LogReaderInput, LogReaderOutput,
275
+ "Read an approved local failure artifact (allowlisted to the "
276
+ "fixtures dir and runs/retro/uploads); returns the parsed log "
277
+ "plus present/missing required fields.",
278
+ read_only=True, timeout_s=5.0)
279
+ reg.register("safety_standard_rag", rag_search, RAGSearchInput, RAGSearchOutput,
280
+ "Retrieve safety-case requirements from the (synthetic, "
281
+ "clearly-labeled) corpus with source/version/section/"
282
+ "relevance-score metadata.",
283
+ read_only=True, timeout_s=20.0)
284
+ reg.register("metric_calculator", metric_calculator, MetricCalcInput,
285
+ MetricCalcOutput,
286
+ "Deterministic safety metrics: stopping_distance (reaction/"
287
+ "latency/friction/grade/decel parameterized), ttc (SSAM "
288
+ "rectangle projection with validity flags), scr_impact "
289
+ "(policy-defined criticality), behavioral_impact (observed vs "
290
+ "corrected-perception planner response).",
291
+ read_only=True, timeout_s=10.0)
292
+ reg.register("historical_failure_search", historical_search,
293
+ HistoricalSearchInput, HistoricalSearchOutput,
294
+ "Similarity search over previously analyzed failures "
295
+ "(persisted retro scorecards).",
296
+ read_only=True, timeout_s=10.0)
297
+ reg.register("distribution_analysis", distribution_analysis,
298
+ DistributionInput, DistributionOutput,
299
+ "Distribution-shift findings: delegates to megaeval stats for "
300
+ "real run ids, else analyzes artifact-provided slice rates.",
301
+ read_only=True, timeout_s=15.0)
302
+ reg.register("create_evaluation_case", create_evaluation_case,
303
+ CreateCaseInput, CreateCaseOutput,
304
+ "WRITE TOOL: persist a new evaluation case candidate derived "
305
+ "from this retrospective. Requires explicit policy "
306
+ "authorization on every call; fully audited.",
307
+ read_only=False, timeout_s=5.0)
308
+ return reg
sensorflow/retro/tools/mcp_server.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bonus: expose the audited tool registry as a real MCP server.
2
+
3
+ The `mcp` python package installed cleanly in this environment, so the
4
+ registry can be served over stdio to any MCP client:
5
+
6
+ .venv/bin/python -m sensorflow.retro.tools.mcp_server
7
+
8
+ Design notes:
9
+ - Every MCP tool call still flows through ToolRegistry.call(), so the audit
10
+ trail, schema validation, timeouts, and write-authorization gate apply
11
+ identically to MCP clients and in-process callers.
12
+ - The write tool (create_evaluation_case) is exposed but the registry will
13
+ deny it unless the client passes policy_authorization=true in arguments —
14
+ the MCP layer cannot bypass the boundary.
15
+ - If `mcp` is not installed this module raises a clear ImportError; the
16
+ registry itself remains the MCP-style boundary (documented in registry.py).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Dict
22
+
23
+ from sensorflow.retro.tools.builtin import build_registry
24
+
25
+
26
+ def _import_server_class():
27
+ """Support both the modern (MCPServer) and older (FastMCP) APIs."""
28
+ try:
29
+ from mcp.server.mcpserver import MCPServer
30
+ return MCPServer
31
+ except ImportError:
32
+ pass
33
+ try:
34
+ from mcp.server.fastmcp import FastMCP
35
+ return FastMCP
36
+ except ImportError as exc: # honest failure; registry remains the boundary
37
+ raise ImportError(
38
+ "the 'mcp' package is not installed (or has an unknown server "
39
+ "API); the ToolRegistry remains the MCP-style boundary "
40
+ "(pip install mcp to enable this server)") from exc
41
+
42
+
43
+ def build_mcp_server():
44
+ """Construct (but do not run) the MCP server wrapping the registry."""
45
+ server_cls = _import_server_class()
46
+ registry = build_registry(analysis_id="mcp-session")
47
+ server = server_cls(name="sensorflow-retro")
48
+
49
+ def make_handler(tool_name: str):
50
+ def handler(arguments: Dict[str, Any] | None = None) -> Dict[str, Any]:
51
+ args = dict(arguments or {})
52
+ authorized = bool(args.pop("policy_authorization", False))
53
+ res = registry.call(tool_name, args, policy_authorization=authorized)
54
+ return res.model_dump()
55
+ return handler
56
+
57
+ for spec in registry.specs():
58
+ server.add_tool(
59
+ make_handler(spec.name),
60
+ name=spec.name,
61
+ description=(f"{spec.description} "
62
+ f"[read_only={spec.read_only}, timeout={spec.timeout_s}s; "
63
+ f"args: single 'arguments' object matching the input "
64
+ f"schema; write tools need policy_authorization=true]"))
65
+ return server, registry
66
+
67
+
68
+ if __name__ == "__main__":
69
+ srv, _ = build_mcp_server()
70
+ srv.run() # stdio transport
sensorflow/retro/tools/registry.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool registry: the MCP-style boundary between the agent and the world.
2
+
3
+ Every tool declares an input schema, an output schema, permissions
4
+ (read_only, default True), a timeout, and error behavior. Every call —
5
+ success or failure — is appended to an audit trail with the arguments, a
6
+ SHA-256 hash of the result, and a UTC timestamp. Write tools additionally
7
+ require an explicit policy-authorization flag per call.
8
+
9
+ If the `mcp` package is installed, tools/mcp_server.py exposes this same
10
+ registry as a real MCP server; otherwise this registry itself is the
11
+ MCP-style boundary.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import concurrent.futures
17
+ import hashlib
18
+ import json
19
+ import time
20
+ import uuid
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Callable, Dict, List, Optional, Type
23
+
24
+ from pydantic import BaseModel, Field, ValidationError
25
+
26
+ from sensorflow.retro import store
27
+
28
+
29
+ class ToolPermissionError(PermissionError):
30
+ pass
31
+
32
+
33
+ class ToolSpec(BaseModel):
34
+ name: str
35
+ description: str
36
+ input_schema: Dict[str, Any]
37
+ output_schema: Dict[str, Any]
38
+ read_only: bool = True
39
+ timeout_s: float = 10.0
40
+ error_behavior: str = "raise" # raise | return_error
41
+
42
+
43
+ class AuditRecord(BaseModel):
44
+ call_id: str
45
+ analysis_id: str
46
+ tool: str
47
+ timestamp: str
48
+ args: Dict[str, Any]
49
+ status: str # ok | error | timeout | denied
50
+ result_hash: Optional[str] # sha256 of canonical result JSON
51
+ elapsed_ms: float
52
+ error: Optional[str] = None
53
+ authorized_write: bool = False
54
+
55
+
56
+ class _RegisteredTool(BaseModel):
57
+ spec: ToolSpec
58
+ fn: Callable[..., BaseModel]
59
+ input_model: Type[BaseModel]
60
+ output_model: Type[BaseModel]
61
+
62
+ model_config = {"arbitrary_types_allowed": True}
63
+
64
+
65
+ def _hash_result(result: Any) -> str:
66
+ blob = json.dumps(result, sort_keys=True, default=str)
67
+ return hashlib.sha256(blob.encode()).hexdigest()
68
+
69
+
70
+ class ToolCallResult(BaseModel):
71
+ ok: bool
72
+ tool: str
73
+ call_id: str
74
+ result: Optional[Dict[str, Any]] = None
75
+ error: Optional[str] = None
76
+ elapsed_ms: float = 0.0
77
+
78
+
79
+ class ToolRegistry:
80
+ """Audited, permissioned, schema-validated tool dispatch."""
81
+
82
+ def __init__(self, analysis_id: Optional[str] = None,
83
+ persist_audit: bool = True):
84
+ self._tools: Dict[str, _RegisteredTool] = {}
85
+ self.analysis_id = analysis_id or f"adhoc-{uuid.uuid4().hex[:8]}"
86
+ self.persist_audit = persist_audit
87
+ self.audit_log: List[AuditRecord] = []
88
+
89
+ # ------------------------------------------------------------ registration
90
+
91
+ def register(self, name: str, fn: Callable[..., BaseModel],
92
+ input_model: Type[BaseModel], output_model: Type[BaseModel],
93
+ description: str, read_only: bool = True,
94
+ timeout_s: float = 10.0, error_behavior: str = "raise") -> None:
95
+ if name in self._tools:
96
+ raise ValueError(f"tool {name!r} already registered")
97
+ if error_behavior not in ("raise", "return_error"):
98
+ raise ValueError("error_behavior must be 'raise' or 'return_error'")
99
+ spec = ToolSpec(name=name, description=description,
100
+ input_schema=input_model.model_json_schema(),
101
+ output_schema=output_model.model_json_schema(),
102
+ read_only=read_only, timeout_s=timeout_s,
103
+ error_behavior=error_behavior)
104
+ self._tools[name] = _RegisteredTool(spec=spec, fn=fn,
105
+ input_model=input_model,
106
+ output_model=output_model)
107
+
108
+ def specs(self) -> List[ToolSpec]:
109
+ return [t.spec for t in self._tools.values()]
110
+
111
+ def get_spec(self, name: str) -> ToolSpec:
112
+ return self._tools[name].spec
113
+
114
+ # ------------------------------------------------------------------ audit
115
+
116
+ def _audit(self, record: AuditRecord) -> None:
117
+ self.audit_log.append(record)
118
+ if self.persist_audit:
119
+ store.append_audit(self.analysis_id, record.model_dump())
120
+
121
+ # ------------------------------------------------------------------- call
122
+
123
+ def call(self, name: str, args: Dict[str, Any],
124
+ policy_authorization: bool = False) -> ToolCallResult:
125
+ call_id = uuid.uuid4().hex[:12]
126
+ t0 = time.perf_counter()
127
+ ts = datetime.now(timezone.utc).isoformat()
128
+
129
+ def audit(status: str, result_hash: Optional[str] = None,
130
+ error: Optional[str] = None) -> None:
131
+ self._audit(AuditRecord(
132
+ call_id=call_id, analysis_id=self.analysis_id, tool=name,
133
+ timestamp=ts, args=args, status=status, result_hash=result_hash,
134
+ elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
135
+ error=error, authorized_write=policy_authorization))
136
+
137
+ if name not in self._tools:
138
+ audit("error", error="unknown tool")
139
+ raise KeyError(f"unknown tool {name!r}")
140
+ tool = self._tools[name]
141
+
142
+ if not tool.spec.read_only and not policy_authorization:
143
+ audit("denied", error="write tool requires policy_authorization=True")
144
+ raise ToolPermissionError(
145
+ f"tool {name!r} is a WRITE tool and requires an explicit "
146
+ "policy_authorization=True flag on the call")
147
+
148
+ try:
149
+ validated = tool.input_model(**args)
150
+ except ValidationError as exc:
151
+ audit("error", error=f"input validation failed: {exc}")
152
+ if tool.spec.error_behavior == "return_error":
153
+ return ToolCallResult(ok=False, tool=name, call_id=call_id,
154
+ error=f"input validation failed: {exc}",
155
+ elapsed_ms=(time.perf_counter() - t0) * 1000)
156
+ raise
157
+
158
+ try:
159
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
160
+ future = pool.submit(tool.fn, validated)
161
+ output = future.result(timeout=tool.spec.timeout_s)
162
+ except concurrent.futures.TimeoutError:
163
+ audit("timeout", error=f"timed out after {tool.spec.timeout_s}s")
164
+ if tool.spec.error_behavior == "return_error":
165
+ return ToolCallResult(ok=False, tool=name, call_id=call_id,
166
+ error=f"timeout after {tool.spec.timeout_s}s",
167
+ elapsed_ms=(time.perf_counter() - t0) * 1000)
168
+ raise TimeoutError(f"tool {name!r} timed out after {tool.spec.timeout_s}s")
169
+ except Exception as exc:
170
+ audit("error", error=f"{exc.__class__.__name__}: {exc}")
171
+ if tool.spec.error_behavior == "return_error":
172
+ return ToolCallResult(ok=False, tool=name, call_id=call_id,
173
+ error=f"{exc.__class__.__name__}: {exc}",
174
+ elapsed_ms=(time.perf_counter() - t0) * 1000)
175
+ raise
176
+
177
+ if not isinstance(output, tool.output_model):
178
+ output = tool.output_model(**(output if isinstance(output, dict)
179
+ else output.model_dump()))
180
+ result_dict = output.model_dump()
181
+ audit("ok", result_hash=_hash_result(result_dict))
182
+ return ToolCallResult(ok=True, tool=name, call_id=call_id,
183
+ result=result_dict,
184
+ elapsed_ms=round((time.perf_counter() - t0) * 1000, 2))
src/App.tsx CHANGED
@@ -30,6 +30,7 @@ import {
30
  Map as MapIcon,
31
  AppWindow,
32
  Mountain,
 
33
  CircuitBoard,
34
  } from 'lucide-react';
35
  import { LabelEvalContext, type PageId, ALL_PAGE_IDS } from './context/LabelEvalContext';
@@ -54,6 +55,7 @@ import RootCauseLabPage from './pages/rca/RootCauseLabPage';
54
  import RareMinePage from './pages/raremine/RareMinePage';
55
  import VitisPage from './pages/vitis/VitisPage';
56
  import HillClimbSection from './pages/hillclimb/HillClimbSection';
 
57
  import PageIntro from './components/help/PageIntro';
58
  import HelpMenu from './components/help/HelpMenu';
59
 
@@ -83,6 +85,7 @@ const PLATFORM_NAV: NavItem[] = [
83
  { id: 'audit', label: 'Audit', icon: <ScrollText size={18} /> },
84
  { id: 'pipeline', label: 'Pipeline Architecture', icon: <Workflow size={18} /> },
85
  { id: 'hillclimb', label: 'Hill Climbing EM', icon: <Mountain size={18} /> },
 
86
  { id: 'vitis', label: 'Hardware Acceleration', icon: <CircuitBoard size={18} /> },
87
  ];
88
 
@@ -109,6 +112,7 @@ const PAGE_TITLES: Record<PageId, string> = {
109
  audit: 'Audit & Process Units',
110
  pipeline: 'Pipeline Architecture',
111
  hillclimb: 'Hill Climbing EM',
 
112
  vitis: 'Hardware Acceleration (Vitis Vision)',
113
  ssam: 'SSAM Safety Dashboard',
114
  legacy: 'Legacy Studio',
@@ -317,6 +321,7 @@ export default function App() {
317
  {page === 'audit' ? <AuditPage /> : null}
318
  {page === 'pipeline' ? <PipelineArchitecturePage /> : null}
319
  {page === 'hillclimb' ? <HillClimbSection initialView={entityId} /> : null}
 
320
  {page === 'vitis' ? <VitisPage /> : null}
321
  </Box>
322
  )}
 
30
  Map as MapIcon,
31
  AppWindow,
32
  Mountain,
33
+ Undo2,
34
  CircuitBoard,
35
  } from 'lucide-react';
36
  import { LabelEvalContext, type PageId, ALL_PAGE_IDS } from './context/LabelEvalContext';
 
55
  import RareMinePage from './pages/raremine/RareMinePage';
56
  import VitisPage from './pages/vitis/VitisPage';
57
  import HillClimbSection from './pages/hillclimb/HillClimbSection';
58
+ import RetroAnalyzerPage from './pages/retro/RetroAnalyzerPage';
59
  import PageIntro from './components/help/PageIntro';
60
  import HelpMenu from './components/help/HelpMenu';
61
 
 
85
  { id: 'audit', label: 'Audit', icon: <ScrollText size={18} /> },
86
  { id: 'pipeline', label: 'Pipeline Architecture', icon: <Workflow size={18} /> },
87
  { id: 'hillclimb', label: 'Hill Climbing EM', icon: <Mountain size={18} /> },
88
+ { id: 'retro', label: 'Retrospective Analyzer', icon: <Undo2 size={18} /> },
89
  { id: 'vitis', label: 'Hardware Acceleration', icon: <CircuitBoard size={18} /> },
90
  ];
91
 
 
112
  audit: 'Audit & Process Units',
113
  pipeline: 'Pipeline Architecture',
114
  hillclimb: 'Hill Climbing EM',
115
+ retro: 'Retrospective Safety Analyzer',
116
  vitis: 'Hardware Acceleration (Vitis Vision)',
117
  ssam: 'SSAM Safety Dashboard',
118
  legacy: 'Legacy Studio',
 
321
  {page === 'audit' ? <AuditPage /> : null}
322
  {page === 'pipeline' ? <PipelineArchitecturePage /> : null}
323
  {page === 'hillclimb' ? <HillClimbSection initialView={entityId} /> : null}
324
+ {page === 'retro' ? <RetroAnalyzerPage /> : null}
325
  {page === 'vitis' ? <VitisPage /> : null}
326
  </Box>
327
  )}
src/components/help/HelpMenu.tsx CHANGED
@@ -185,6 +185,7 @@ const PAGE_ORDER: PageId[] = [
185
  'audit',
186
  'pipeline',
187
  'hillclimb',
 
188
  'vitis',
189
  'ssam',
190
  'legacy',
@@ -208,6 +209,7 @@ const PAGE_NAMES: Record<PageId, string> = {
208
  audit: 'Audit',
209
  pipeline: 'Pipeline Architecture',
210
  hillclimb: 'Hill Climbing EM',
 
211
  vitis: 'Hardware Acceleration',
212
  ssam: 'SSAM Safety',
213
  legacy: 'Legacy Studio',
 
185
  'audit',
186
  'pipeline',
187
  'hillclimb',
188
+ 'retro',
189
  'vitis',
190
  'ssam',
191
  'legacy',
 
209
  audit: 'Audit',
210
  pipeline: 'Pipeline Architecture',
211
  hillclimb: 'Hill Climbing EM',
212
+ retro: 'Retrospective Analyzer',
213
  vitis: 'Hardware Acceleration',
214
  ssam: 'SSAM Safety',
215
  legacy: 'Legacy Studio',