Spaces:
Paused
Paused
Deploy Sensorflow Studio
Browse files- .gitattributes +6 -0
- app_backend.py +5 -0
- docs/retro/compatibility-matrix.md +80 -0
- docs/retro/final-report.md +205 -0
- docs/retro/screenshots/01-hardware-compat-card.png +3 -0
- docs/retro/screenshots/02-evidence-chain-observed.png +3 -0
- docs/retro/screenshots/03-evidence-chain-policy-gate.png +3 -0
- docs/retro/screenshots/04-scorecard.png +3 -0
- docs/retro/screenshots/05-retrieved-standards.png +3 -0
- docs/retro/screenshots/06-audit-trail.png +3 -0
- sensorflow/retro/README.md +94 -0
- sensorflow/retro/__init__.py +24 -0
- sensorflow/retro/agent/__init__.py +11 -0
- sensorflow/retro/agent/orchestrator.py +642 -0
- sensorflow/retro/agent/prompts.py +48 -0
- sensorflow/retro/agent/scripted.py +133 -0
- sensorflow/retro/api.py +135 -0
- sensorflow/retro/fixtures/benign_fp_static_sign.json +85 -0
- sensorflow/retro/fixtures/missed_pedestrian_rain.json +99 -0
- sensorflow/retro/fixtures/missing_telemetry_variant.json +70 -0
- sensorflow/retro/fixtures/noncritical_fn_parked_vehicle.json +73 -0
- sensorflow/retro/fixtures/phantom_brake_plastic_bag.json +97 -0
- sensorflow/retro/inference/__init__.py +2 -0
- sensorflow/retro/inference/client.py +272 -0
- sensorflow/retro/inference/compat.py +241 -0
- sensorflow/retro/inference/env_detect.py +228 -0
- sensorflow/retro/inference/vllm_server/benchmark.py +154 -0
- sensorflow/retro/inference/vllm_server/model.env +6 -0
- sensorflow/retro/inference/vllm_server/runtime.env +4 -0
- sensorflow/retro/inference/vllm_server/server.env +5 -0
- sensorflow/retro/inference/vllm_server/start_vllm.sh +47 -0
- sensorflow/retro/metrics.py +239 -0
- sensorflow/retro/policy.py +288 -0
- sensorflow/retro/rag/__init__.py +8 -0
- sensorflow/retro/rag/chunking.py +74 -0
- sensorflow/retro/rag/corpus.py +281 -0
- sensorflow/retro/rag/embedder.py +64 -0
- sensorflow/retro/rag/evalharness.py +83 -0
- sensorflow/retro/rag/retriever.py +81 -0
- sensorflow/retro/rag/store.py +112 -0
- sensorflow/retro/requirements-retro.txt +27 -0
- sensorflow/retro/scorecard.py +240 -0
- sensorflow/retro/store.py +86 -0
- sensorflow/retro/tools/__init__.py +1 -0
- sensorflow/retro/tools/builtin.py +308 -0
- sensorflow/retro/tools/mcp_server.py +70 -0
- sensorflow/retro/tools/registry.py +184 -0
- src/App.tsx +5 -0
- 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
|
docs/retro/screenshots/02-evidence-chain-observed.png
ADDED
|
Git LFS Details
|
docs/retro/screenshots/03-evidence-chain-policy-gate.png
ADDED
|
Git LFS Details
|
docs/retro/screenshots/04-scorecard.png
ADDED
|
Git LFS Details
|
docs/retro/screenshots/05-retrieved-standards.png
ADDED
|
Git LFS Details
|
docs/retro/screenshots/06-audit-trail.png
ADDED
|
Git LFS Details
|
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',
|