ssookra commited on
Commit
a464cc6
·
verified ·
1 Parent(s): f37fa31

wave-48 backend deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +95 -0
  2. README.md +31 -6
  3. apex/__init__.py +1 -0
  4. apex/guardian/.gitkeep +0 -0
  5. apex/guardian/__init__.py +7 -0
  6. apex/guardian/audit.py +308 -0
  7. apex/instruct/.gitkeep +0 -0
  8. apex/instruct/__init__.py +1 -0
  9. apex/instruct/coa_parser.py +169 -0
  10. apex/instruct/g1b_latency_bench.py +149 -0
  11. apex/instruct/narrator.py +475 -0
  12. apex/instruct/openrouter_generator.py +183 -0
  13. apex/instruct/sarah_synth.py +188 -0
  14. apex/instruct/timing_sheet_parser.py +117 -0
  15. apex/intake/.gitkeep +0 -0
  16. apex/intake/__init__.py +6 -0
  17. apex/intake/cache.py +64 -0
  18. apex/langflow/.gitkeep +0 -0
  19. apex/observability.py +101 -0
  20. apex/orchestration/__init__.py +8 -0
  21. apex/orchestration/audit_log.py +155 -0
  22. apex/orchestration/langgraph_runtime.py +305 -0
  23. apex/orchestration/session_context.py +138 -0
  24. apex/orchestration/what_if_replay.py +156 -0
  25. apex/physics/.gitkeep +0 -0
  26. apex/physics/__init__.py +1 -0
  27. apex/physics/projection.py +164 -0
  28. apex/physics/scp_spike.py +222 -0
  29. apex/physics/validator.py +312 -0
  30. apex/pipelines/__init__.py +7 -0
  31. apex/pipelines/g4_mae_bakeoff.py +319 -0
  32. apex/pipelines/sarah_e2e.py +125 -0
  33. apex/pipelines/telemetry_to_log.py +237 -0
  34. apex/schemas.py +185 -0
  35. apex/server.py +517 -0
  36. apex/shared/__init__.py +1 -0
  37. apex/shared/contracts/__init__.py +56 -0
  38. apex/shared/contracts/adapters.py +78 -0
  39. apex/shared/contracts/projector.py +101 -0
  40. apex/shared/contracts/shapes.py +81 -0
  41. apex/shared/contracts/violations.py +217 -0
  42. apex/shared/logging.py +182 -0
  43. apex/tspulse/__init__.py +19 -0
  44. apex/tspulse/anomaly.py +230 -0
  45. apex/ttm/.gitkeep +0 -0
  46. apex/ttm/__init__.py +1 -0
  47. apex/ttm/forecast.py +206 -0
  48. apex/ttm/g1_smoke.py +176 -0
  49. apex/vision/.gitkeep +0 -0
  50. fixtures/personas/sarah-reynolds-coa-stub.json +131 -0
Dockerfile ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # APEX backend container (Phase 5 task 5.2 + wave-48 deploy fixes)
2
+ #
3
+ # Build:
4
+ # docker build -t apex-backend:0.2.0 app/backend
5
+ #
6
+ # Run (local):
7
+ # docker run --rm -p 8000:8000 \
8
+ # -v ${PWD}/fixtures:/srv/fixtures:ro \
9
+ # -e APEX_AUDIT_LOG_PATH=/srv/audit/audit-log.jsonl \
10
+ # -e OPENROUTER_API_KEY=$OPENROUTER_API_KEY \
11
+ # apex-backend:0.2.0
12
+ #
13
+ # Smoke (after `docker run`):
14
+ # curl http://localhost:8000/healthz
15
+ # curl http://localhost:8000/api/session-context
16
+ # curl http://localhost:8000/api/orchestration
17
+ #
18
+ # Production deploy targets (per Phase 5 task 5.1):
19
+ # - HuggingFace Spaces (Docker SDK, free CPU tier)
20
+ # 1. Create Space at huggingface.co/new-space (Docker SDK, CPU basic)
21
+ # 2. Copy app/backend/* + fixtures/ into the Space repo
22
+ # 3. Add README.md HF Spaces frontmatter (see app/backend/README.md)
23
+ # 4. git push to huggingface.co; Spaces builds + serves automatically
24
+ # - Fly.io: `fly launch --no-deploy --image apex-backend:0.2.0`
25
+ # - Modal: `modal deploy` from a wrapped `modal.Image.from_dockerfile()`
26
+ #
27
+ # wave-48 fixes vs the v0.1.0 Dockerfile:
28
+ # 1. `pip install -r requirements.txt` for real (was pinning only 4
29
+ # packages by name; cvxpylayers + transformers were silently
30
+ # missing). Skipped torch+cuda; install CPU torch separately for
31
+ # size.
32
+ # 2. `COPY fixtures` so `/api/analyze` + `/api/orchestration` can
33
+ # resolve `fixtures/personas/sarah-reynolds-*` on a fresh
34
+ # container (was 503-ing because fixtures were dev-only).
35
+ # 3. HEALTHCHECK extended to 60s start period to accommodate TTM lazy
36
+ # load when `APEX_ENABLE_TTM=1`.
37
+
38
+ FROM python:3.11-slim AS base
39
+
40
+ # --- system deps -------------------------------------------------------
41
+ RUN apt-get update \
42
+ && apt-get install -y --no-install-recommends \
43
+ git \
44
+ curl \
45
+ build-essential \
46
+ && rm -rf /var/lib/apt/lists/*
47
+
48
+ WORKDIR /srv/app
49
+
50
+ # --- python deps (light layer; CPU torch only, no cuda) ---------------
51
+ # Install CPU-only torch first (much smaller than CUDA wheel; CPU is
52
+ # what HF Spaces free tier provides). cvxpy + cvxpylayers + scs +
53
+ # clarabel + transformers + granite-tsfm + httpx all install via the
54
+ # main requirements.txt pin.
55
+ COPY requirements.txt requirements.txt
56
+ RUN pip install --no-cache-dir --upgrade pip wheel setuptools
57
+ RUN pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cpu
58
+ # requirements.txt was authored against Vinh's Windows+CUDA env; filter
59
+ # out the torch line + install the rest. Anything we don't need at
60
+ # runtime (llama_cpp_python is heavy + only used by the offline G1b
61
+ # bench script, never by the FastAPI server) is dropped via the grep.
62
+ RUN grep -vE "^(torch==|llama_cpp_python==|fastf1==)" requirements.txt > requirements.lean.txt \
63
+ && pip install --no-cache-dir -r requirements.lean.txt \
64
+ && pip install --no-cache-dir \
65
+ "uvicorn[standard]" \
66
+ python-multipart \
67
+ structlog
68
+
69
+ # --- app code + fixtures ----------------------------------------------
70
+ # COPY assumes the build context contains: apex/ + fixtures/ + (optional)
71
+ # docs/. For HF Spaces deploy, the deploy script symlinks/copies
72
+ # fixtures from `../../fixtures` into the build context first; for
73
+ # repo-root `docker build -f app/backend/Dockerfile .` the COPY paths
74
+ # below still resolve. Local-only `docker build app/backend` requires
75
+ # `cp -r ../../fixtures app/backend/fixtures` BEFORE building.
76
+ COPY apex apex
77
+ COPY fixtures /srv/app/fixtures
78
+
79
+ # --- runtime ----------------------------------------------------------
80
+ ENV PYTHONPATH=/srv/app \
81
+ APEX_AUDIT_LOG_PATH=/srv/audit/audit-log.jsonl \
82
+ APEX_COMMIT_SHA=container \
83
+ APEX_ENABLE_TTM=0 \
84
+ PYTHONUNBUFFERED=1
85
+
86
+ RUN mkdir -p /srv/audit && chmod 0777 /srv/audit
87
+
88
+ EXPOSE 7860 8000
89
+
90
+ # HF Spaces routes traffic to port 7860 by default; uvicorn binds both
91
+ # to keep local docker-run + Fly.io + HF Spaces all happy.
92
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \
93
+ CMD curl -fsS http://localhost:${PORT:-7860}/healthz || curl -fsS http://localhost:8000/healthz || exit 1
94
+
95
+ CMD ["sh", "-c", "uvicorn apex.server:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md CHANGED
@@ -1,12 +1,37 @@
1
  ---
2
- title: Apex Backend
3
- emoji: 📊
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
- short_description: APEX race-engineer backend - FastAPI + LangGraph 6-node runt
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: APEX Backend
3
+ emoji: 🏎️
4
+ colorFrom: green
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: apache-2.0
10
+ short_description: APEX race-engineer backend (FastAPI + LangGraph + Granite)
11
  ---
12
 
13
+ # APEX Backend on HuggingFace Spaces
14
+
15
+ This Space hosts the **APEX** race-engineer backend for the IBM SkillsBuild AI Builders Challenge May 2026 submission. The frontend lives at <https://apex-one-black.vercel.app>; this Space is the Python FastAPI backend it calls.
16
+
17
+ ## Endpoints
18
+
19
+ | Method | Path | Purpose |
20
+ |--------|------|---------|
21
+ | GET | `/healthz` | container readiness probe |
22
+ | GET | `/api/session-context` | race-session tile feed |
23
+ | GET | `/api/orchestration` | LangGraph 6-node trace on canonical Sarah Reynolds fixture |
24
+ | POST | `/api/audit-log` | append-only Guardian audit log (JSONL + flock) |
25
+ | POST | `/api/what-if-replay` | byte-deterministic V2 cvxpylayers replay |
26
+ | POST | `/api/analyze` | end-to-end pipeline (JSON file-path input; legacy) |
27
+ | POST | `/api/analyze-upload` | end-to-end pipeline (multipart upload; wave-48) |
28
+
29
+ ## Honesty surface
30
+
31
+ - Forecast engine: deterministic seasonal-naive baseline. Frozen TTM r2 wire is gated behind `APEX_ENABLE_TTM=1` Space env (see Phase 4 Day 4 G4 FAIL pivot in [`logs/day-04-g4.md`](https://github.com/StephenSook/apex/blob/main/logs/day-04-g4.md)).
32
+ - Narrator engine: OpenRouter Granite 4.1 8B when `OPENROUTER_API_KEY` is set; deterministic template floor otherwise. Honest engine surfaced in the per-node trace detail.
33
+ - Orchestration: deterministic 6-node Python state machine modeled on LangGraph semantics; not the `langgraph` package runtime. See [`docs/decision-log.md`](https://github.com/StephenSook/apex/blob/main/docs/decision-log.md) D-067.
34
+
35
+ ## License
36
+
37
+ Apache 2.0. Repo: <https://github.com/StephenSook/apex>.
apex/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """APEX backend package."""
apex/guardian/.gitkeep ADDED
File without changes
apex/guardian/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Granite Guardian 4.1 BYOC custom-rules audit layer.
2
+
3
+ Reads a PhysicsViolationLog + CoaParseResult, applies a BYOC rule
4
+ registry, emits a GuardianAudit discriminated union (approve | flag |
5
+ reject) that mirrors the canonical frontend contract at
6
+ `app/shared/types.ts` L323-345.
7
+ """
apex/guardian/audit.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Granite Guardian 4.1 BYOC custom-rules audit (Phase 2 Day 5 task 2.14).
2
+
3
+ Reads a PhysicsViolationLog (engine-agnostic; V1 NumPy or V2 cvxpylayers
4
+ both work) + a CoaParseResult, applies a BYOC rule registry, emits a
5
+ GuardianAudit discriminated union (approve | flag | reject) whose shape
6
+ mirrors the canonical frontend contract at `app/shared/types.ts` L323-345.
7
+
8
+ BYOC rule schema follows docs/architecture-spec.md L187-204. Each rule:
9
+ - matches ViolationRecords by violation_type
10
+ - maps to a verdict (approve | flag | reject)
11
+ - carries optional templated strings for the audit's reasoning_trace +
12
+ flagged_concerns + blocked_recommendations fields
13
+
14
+ Verdict precedence: reject > flag > approve. If any rule fires with a
15
+ reject branch, the top-level verdict is reject (D-022 lexicographic
16
+ Tier-0 inviolable contract: COA-derived violations are inviolable).
17
+
18
+ The actual Granite Guardian 4.1 model integration (BYOC custom prompt +
19
+ think-mode trace) lands at task 2.15 + Phase 3 + Phase 4 orchestration.
20
+ This module ships the deterministic rule-engine floor that the
21
+ Guardian model wraps; the engine-agnostic boundary means Gate G5 can
22
+ pass on the rule-engine floor even before the Granite model is wired.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Final
29
+
30
+ from apex.instruct.coa_parser import CoaParseResult
31
+ from apex.shared.contracts import (
32
+ GuardianAudit,
33
+ GuardianVerdict,
34
+ PhysicsViolationLog,
35
+ ViolationRecord,
36
+ new_audit_id,
37
+ )
38
+
39
+
40
+ # ---- BYOC rule registry -------------------------------------------------
41
+
42
+ @dataclass(frozen=True)
43
+ class BYOCRule:
44
+ """A Bring-Your-Own-Classifier rule for the Granite Guardian audit.
45
+
46
+ Concrete shape per docs/architecture-spec.md L187-204. Each rule
47
+ matches a single violation_type + emits a single verdict; multi-
48
+ verdict rules from the architecture-spec's verdict_map are
49
+ represented as separate BYOCRule instances (one per
50
+ severity-trigger).
51
+
52
+ Templated fields use str.format() placeholders: {step}, {long_g},
53
+ {lat_g}, {throttle_pct}, {brake_pa}, {speed_mps}, {severity},
54
+ {tier}. Templates that reference a field absent from the
55
+ violation's channel_values fall back to the literal placeholder
56
+ string (no crash on missing fields).
57
+ """
58
+
59
+ rule_id: str
60
+ violation_type: str
61
+ verdict: GuardianVerdict
62
+ concern_template: str | None = None # used on verdict="flag"
63
+ block_template: str | None = None # used on verdict="reject"
64
+ reasoning_template: str | None = None # appended to reasoning_trace on any match
65
+
66
+
67
+ def _format_template(template: str, record: ViolationRecord) -> str:
68
+ """Format a BYOC template against a ViolationRecord.
69
+
70
+ Substitutes {step}, {type}, {severity}, {tier} from record fields
71
+ and every key in record.channel_values. Missing placeholders fall
72
+ back to the literal `{placeholder}` string.
73
+ """
74
+ fields: dict[str, object] = {
75
+ "step": record.step,
76
+ "type": record.type,
77
+ "severity": f"{record.severity:.4f}",
78
+ "tier": record.tier,
79
+ }
80
+ fields.update({k: f"{v:.4f}" for k, v in record.channel_values.items()})
81
+ try:
82
+ return template.format(**fields)
83
+ except (KeyError, IndexError):
84
+ return template
85
+
86
+
87
+ DEFAULT_RULE_REGISTRY: Final[tuple[BYOCRule, ...]] = (
88
+ BYOCRule(
89
+ rule_id="friction_ellipse_breach",
90
+ violation_type="friction_ellipse_exceeded",
91
+ verdict="flag",
92
+ concern_template=(
93
+ "Friction-ellipse breach at step {step}: long_g={long_g}, "
94
+ "lat_g={lat_g} exceeds the constant-mu envelope by "
95
+ "{severity}g. Constraint tier {tier}."
96
+ ),
97
+ reasoning_template=(
98
+ "Rule friction_ellipse_breach fired on step {step} "
99
+ "(severity {severity})."
100
+ ),
101
+ ),
102
+ BYOCRule(
103
+ rule_id="forward_euler_inconsistency",
104
+ violation_type="forward_euler_inconsistent",
105
+ verdict="flag",
106
+ concern_template=(
107
+ "Forward-Euler kinematic break at step {step}: Delta-v vs "
108
+ "long_g residual exceeds the 1 Hz tolerance band by {severity} m/s."
109
+ ),
110
+ reasoning_template=(
111
+ "Rule forward_euler_inconsistency fired on step {step}."
112
+ ),
113
+ ),
114
+ BYOCRule(
115
+ rule_id="bicycle_kinematic_break",
116
+ violation_type="bicycle_kinematic_break",
117
+ verdict="flag",
118
+ concern_template=(
119
+ "Bicycle-model kinematic break at step {step}: lat_g={lat_g} "
120
+ "vs steering_rad={steering_rad} at speed_mps={speed_mps} "
121
+ "disagrees by {severity}g."
122
+ ),
123
+ reasoning_template=(
124
+ "Rule bicycle_kinematic_break fired on step {step}. V1 "
125
+ "small-angle bicycle model is known to false-positive at "
126
+ "race-corner speeds; V2 cvxpylayers + 8-tier Pacejka "
127
+ "supersedes this check at production fidelity."
128
+ ),
129
+ ),
130
+ BYOCRule(
131
+ rule_id="coa_simultaneity_breach",
132
+ violation_type="coa_simultaneity_violation",
133
+ verdict="reject",
134
+ block_template=(
135
+ "Cannot approve coaching recommendation: COA does not "
136
+ "permit simultaneous throttle + brake input at step {step} "
137
+ "(throttle_pct={throttle_pct}, brake_pa={brake_pa}). "
138
+ "This is a Tier-0 inviolable constraint per D-022 "
139
+ "lexicographic COA hierarchy."
140
+ ),
141
+ reasoning_template=(
142
+ "Rule coa_simultaneity_breach fired on step {step}: "
143
+ "Tier-0 COA-derived simultaneity gate (inviolable)."
144
+ ),
145
+ ),
146
+ )
147
+ """Default BYOC rule registry covering the V1 NumPy validator's four
148
+ violation types + the Tier-0 COA gate. Convergence-14 expansion to the
149
+ remaining 10 violation types lands at Phase 4 task 4.2."""
150
+
151
+
152
+ # ---- Verdict precedence -------------------------------------------------
153
+
154
+ _VERDICT_PRECEDENCE: Final[dict[GuardianVerdict, int]] = {
155
+ "approve": 0,
156
+ "flag": 1,
157
+ "reject": 2,
158
+ }
159
+
160
+
161
+ def _max_verdict(a: GuardianVerdict, b: GuardianVerdict) -> GuardianVerdict:
162
+ return a if _VERDICT_PRECEDENCE[a] >= _VERDICT_PRECEDENCE[b] else b
163
+
164
+
165
+ # ---- Guardian -----------------------------------------------------------
166
+
167
+ class Guardian:
168
+ """Granite Guardian 4.1 BYOC custom-rules audit driver.
169
+
170
+ Stateless aside from the rule registry; .audit() can be called many
171
+ times on the same instance. Each call generates a fresh audit_id
172
+ via shared.contracts.violations.new_audit_id().
173
+
174
+ The Granite model itself is NOT loaded here; this class ships the
175
+ deterministic rule-engine floor that the Guardian model wraps.
176
+ Task 2.15 + Phase 4 wire the model in. Gate G5 (task 2.16) passes
177
+ on the rule-engine floor because the floor catches all 5
178
+ impossibilities + emits the right verdict.
179
+ """
180
+
181
+ def __init__(self, rules: tuple[BYOCRule, ...] | None = None):
182
+ self._rules = rules if rules is not None else DEFAULT_RULE_REGISTRY
183
+
184
+ def audit(
185
+ self,
186
+ *,
187
+ violation_log: PhysicsViolationLog,
188
+ coa: CoaParseResult,
189
+ ) -> GuardianAudit:
190
+ """Apply the BYOC rule registry to `violation_log` + `coa`.
191
+
192
+ Returns a GuardianAudit whose verdict is the maximum-precedence
193
+ verdict across every rule that fired (approve if none fired).
194
+ """
195
+ audit_id = new_audit_id()
196
+ reasoning_trace: list[str] = []
197
+ flagged_concerns: list[str] = []
198
+ blocked_recommendations: list[str] = []
199
+ top_verdict: GuardianVerdict = "approve"
200
+
201
+ # Empty log + safe CoA: approve with a single reasoning line.
202
+ if violation_log.is_empty():
203
+ reasoning_trace.append(
204
+ f"Empty violation log on {violation_log.engine}; "
205
+ f"COA driver_id={coa.driver_id} simultaneity_permitted="
206
+ f"{coa.simultaneity_permitted}. No rules fired."
207
+ )
208
+ return GuardianAudit(
209
+ verdict="approve",
210
+ reasoning_trace=tuple(reasoning_trace),
211
+ audit_id=audit_id,
212
+ )
213
+
214
+ rules_by_type: dict[str, list[BYOCRule]] = {}
215
+ for rule in self._rules:
216
+ rules_by_type.setdefault(rule.violation_type, []).append(rule)
217
+
218
+ for record in violation_log.records:
219
+ for rule in rules_by_type.get(record.type, []):
220
+ if rule.reasoning_template:
221
+ reasoning_trace.append(
222
+ _format_template(rule.reasoning_template, record)
223
+ )
224
+ if rule.verdict == "flag" and rule.concern_template:
225
+ flagged_concerns.append(
226
+ _format_template(rule.concern_template, record)
227
+ )
228
+ elif rule.verdict == "reject" and rule.block_template:
229
+ blocked_recommendations.append(
230
+ _format_template(rule.block_template, record)
231
+ )
232
+ top_verdict = _max_verdict(top_verdict, rule.verdict)
233
+
234
+ if not reasoning_trace:
235
+ reasoning_trace.append(
236
+ f"Violation log on {violation_log.engine} carried "
237
+ f"{len(violation_log.records)} record(s) but no BYOC rule "
238
+ f"matched. Default verdict: approve."
239
+ )
240
+
241
+ return GuardianAudit(
242
+ verdict=top_verdict,
243
+ reasoning_trace=tuple(reasoning_trace),
244
+ audit_id=audit_id,
245
+ flagged_concerns=tuple(flagged_concerns),
246
+ blocked_recommendations=tuple(blocked_recommendations),
247
+ )
248
+
249
+
250
+ # ---- UI text-render helper (task 2.15) ---------------------------------
251
+
252
+ _RENDER_MODES: Final[tuple[str, ...]] = ("think", "no-think")
253
+
254
+
255
+ def render_audit(audit: GuardianAudit, mode: str = "think") -> str:
256
+ """Render a GuardianAudit as UI-consumable text.
257
+
258
+ Two modes per the Granite Guardian 4.1 hybrid-thinking surface
259
+ documented at docs/architecture-spec.md L440:
260
+
261
+ - 'think' (default): includes the full reasoning_trace chain so
262
+ the UI can show the audit's thinking. Used in the /analyze
263
+ Guardian panel + the provenance footer.
264
+ - 'no-think': verdict header + concerns/blocks + audit_id only.
265
+ Used in low-latency surfaces (coaching-report header banner)
266
+ where the reasoning chain would be visually noisy.
267
+
268
+ Output is plain text; the frontend renderer (GuardianAudit
269
+ component, wave-42) handles its own markdown / structure parsing
270
+ from the discriminated-union audit object. This helper is for
271
+ backend log surfaces (provenance footer, BeMyApp submission
272
+ artifacts, paper §4 reproducibility appendix).
273
+ """
274
+ if mode not in _RENDER_MODES:
275
+ raise ValueError(
276
+ f"render_audit mode must be one of {_RENDER_MODES}; got {mode!r}."
277
+ )
278
+
279
+ lines: list[str] = []
280
+ lines.append(f"GUARDIAN AUDIT verdict={audit.verdict} audit_id={audit.audit_id}")
281
+
282
+ if audit.verdict == "flag" and audit.flagged_concerns:
283
+ lines.append("")
284
+ lines.append("Flagged concerns:")
285
+ for concern in audit.flagged_concerns:
286
+ lines.append(f" - {concern}")
287
+
288
+ if audit.verdict == "reject" and audit.blocked_recommendations:
289
+ lines.append("")
290
+ lines.append("Blocked recommendations:")
291
+ for block in audit.blocked_recommendations:
292
+ lines.append(f" - {block}")
293
+
294
+ if mode == "think" and audit.reasoning_trace:
295
+ lines.append("")
296
+ lines.append("Reasoning trace:")
297
+ for step in audit.reasoning_trace:
298
+ lines.append(f" - {step}")
299
+
300
+ return "\n".join(lines) + "\n"
301
+
302
+
303
+ __all__ = [
304
+ "BYOCRule",
305
+ "DEFAULT_RULE_REGISTRY",
306
+ "Guardian",
307
+ "render_audit",
308
+ ]
apex/instruct/.gitkeep ADDED
File without changes
apex/instruct/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """APEX instruct layer: Granite 4.1 8B Instruct narrator + provenance footer."""
apex/instruct/coa_parser.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COA (Certificate of Adaptations) parser.
2
+
3
+ Phase 1 tasks 1.1 + 1.9. Reads a Sarah-style FIA Appendix L COA JSON stub
4
+ and derives the canonical `simultaneity_permitted` scalar from the approved
5
+ hardware specifications + medical findings, NOT from an explicit FIA Article
6
+ field (per Perplexity validation 2026-05-21 + decision-log D-022 +
7
+ docs/sarah-reynolds-persona.md).
8
+
9
+ The Granite-Docling 258M PDF -> JSON path is a Phase 1.5 swap-point; this
10
+ module ships the JSON-first ingestion now so the rest of the pipeline (V1
11
+ NumPy validator, V2 cvxpylayers projector, narrator, Guardian) can consume
12
+ a stable `CoaParseResult` while the PDF parse matures.
13
+
14
+ Per docs/vinh-backend-plan.md Phase 1 wave-44 path migration: this module
15
+ lives under `instruct/` (not `intake/`) mirroring the rest of the COA + LLM
16
+ domain code.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Mapping
25
+
26
+ ADAPTATION_DOMAINS: tuple[str, ...] = (
27
+ "coa_sec_hand_controls",
28
+ "coa_sec_simultaneity",
29
+ "coa_sec_egress",
30
+ "coa_sec_thermal",
31
+ "medical_findings",
32
+ "adaptive_equipment_specifications",
33
+ "certificate_metadata",
34
+ "driver_metadata",
35
+ "issuing_authority",
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CoaConditionalApproval:
41
+ article_section: str
42
+ fia_appendix_l_reference: str
43
+ condition: str
44
+ approval_status: str
45
+ rationale: str
46
+ evidence_log_id: str | None = None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CoaParseResult:
51
+ """Canonical Phase 1 output. Consumed by:
52
+ - shared.contracts.build_ttm_input() (tiles `simultaneity_permitted`
53
+ across the per-step coa_overlap_flag channel of TENSOR_SHAPE)
54
+ - physics.validator.coa_simultaneity_rule (Phase 2)
55
+ - instruct.narrator (Phase 3, citations resolve against
56
+ `conditional_approvals` article_section IDs)
57
+ """
58
+
59
+ driver_id: str
60
+ certificate_number: str
61
+ fia_appendix_l_revision: str
62
+ simultaneity_permitted: bool
63
+ conditional_approvals: tuple[CoaConditionalApproval, ...]
64
+ adaptation_domains_present: frozenset[str]
65
+ raw: Mapping[str, Any] = field(repr=False)
66
+
67
+
68
+ class CoaParseError(ValueError):
69
+ """Raised when the COA JSON is missing required schema fields."""
70
+
71
+
72
+ def parse_coa_json(path: str | Path) -> CoaParseResult:
73
+ """Parse a Sarah-style COA JSON stub from disk into a CoaParseResult.
74
+
75
+ Schema is the wave-42 `sarah-reynolds-coa-stub.json` shape; see
76
+ `fixtures/personas/sarah-reynolds-coa-stub.json` for the canonical
77
+ example. Raises CoaParseError if any required top-level key is missing.
78
+ """
79
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
80
+ return parse_coa_payload(payload)
81
+
82
+
83
+ def parse_coa_payload(payload: Mapping[str, Any]) -> CoaParseResult:
84
+ for required in ("driver_id", "certificate_metadata", "fia_appendix_l_conditional_approvals"):
85
+ if required not in payload:
86
+ raise CoaParseError(f"COA payload missing required field: {required!r}")
87
+
88
+ cert_meta = payload["certificate_metadata"]
89
+ approvals = tuple(
90
+ CoaConditionalApproval(
91
+ article_section=a["article_section"],
92
+ fia_appendix_l_reference=a["fia_appendix_l_reference"],
93
+ condition=a["condition"],
94
+ approval_status=a["approval_status"],
95
+ rationale=a["rationale"],
96
+ evidence_log_id=a.get("evidence_log_id"),
97
+ )
98
+ for a in payload["fia_appendix_l_conditional_approvals"]
99
+ )
100
+
101
+ domains_present = frozenset(
102
+ domain
103
+ for domain in ADAPTATION_DOMAINS
104
+ if domain in payload or any(a.article_section == domain for a in approvals)
105
+ )
106
+
107
+ return CoaParseResult(
108
+ driver_id=payload["driver_id"],
109
+ certificate_number=cert_meta["certificate_number"],
110
+ fia_appendix_l_revision=cert_meta["fia_appendix_l_revision"],
111
+ simultaneity_permitted=derive_simultaneity_flag(payload),
112
+ conditional_approvals=approvals,
113
+ adaptation_domains_present=domains_present,
114
+ raw=payload,
115
+ )
116
+
117
+
118
+ def derive_simultaneity_flag(payload: Mapping[str, Any]) -> bool:
119
+ """Derive the COA simultaneity-permission scalar from approved hardware
120
+ specs + medical findings.
121
+
122
+ Per Perplexity validation 2026-05-21: APEX does NOT read an explicit FIA
123
+ Article field. The flag is derived from two text anchors named in the
124
+ Sarah stub's `annotations_for_extraction_pipeline.extraction_text_anchors`:
125
+
126
+ 1. fia_appendix_l_conditional_approvals[*] entry with
127
+ article_section == "coa_sec_simultaneity" AND
128
+ condition == "simultaneity_permitted" AND
129
+ approval_status == "approved"
130
+
131
+ 2. adaptive_equipment_specifications.hand_control_configuration.
132
+ simultaneity_geometry contains "independent lever paths"
133
+
134
+ BOTH anchors must agree. If the document root has an explicit
135
+ `simultaneity_permission_flag` boolean (Sarah stub L121), it is used as a
136
+ consistency check against the derived value; mismatch raises
137
+ CoaParseError so we never silently disagree with the fixture.
138
+ """
139
+ approval_anchor = False
140
+ for a in payload.get("fia_appendix_l_conditional_approvals", ()):
141
+ if (
142
+ a.get("article_section") == "coa_sec_simultaneity"
143
+ and a.get("condition") == "simultaneity_permitted"
144
+ and a.get("approval_status") == "approved"
145
+ ):
146
+ approval_anchor = True
147
+ break
148
+
149
+ hardware_anchor = False
150
+ hw_config = (
151
+ payload.get("adaptive_equipment_specifications", {})
152
+ .get("hand_control_configuration", {})
153
+ )
154
+ geometry = hw_config.get("simultaneity_geometry", "")
155
+ if isinstance(geometry, str) and "independent lever paths" in geometry.lower():
156
+ hardware_anchor = True
157
+
158
+ derived = approval_anchor and hardware_anchor
159
+
160
+ explicit = payload.get("simultaneity_permission_flag")
161
+ if isinstance(explicit, bool) and explicit != derived:
162
+ raise CoaParseError(
163
+ "Derived simultaneity flag disagrees with explicit "
164
+ f"`simultaneity_permission_flag` in payload: derived={derived}, "
165
+ f"explicit={explicit}. Refusing to silently resolve; fix the COA "
166
+ "source or the derivation anchors."
167
+ )
168
+
169
+ return derived
apex/instruct/g1b_latency_bench.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """G1b - Granite 4.1 8B Q4_K_M GGUF latency bench on RTX 3060 Ti (Phase 0 task 0.8).
2
+
3
+ Measures tokens/sec on a representative 300-word coaching-report prompt.
4
+ Council v2 implication: G1b feeds into the aLoRA hot-swap + EAGLE-3 deploy
5
+ decision (D-019 items 2 + 4). The G8 wall-clock budget is 60s end-to-end
6
+ with a 15s coaching-report sub-budget (post-D-019 EAGLE-3 + aLoRA
7
+ tightening); G1b tells us how much headroom Granite has before EAGLE-3
8
+ speculative decoding is mandatory vs nice-to-have.
9
+
10
+ Pass criterion: tokens/sec measured + logged. No fail criterion at this
11
+ phase; this is a baseline number for the 9 PM Discord sync with Stephen.
12
+
13
+ Run from repo root:
14
+ app/backend/.venv/Scripts/python.exe -u app/backend/apex/instruct/g1b_latency_bench.py
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[4]
24
+ sys.path.insert(0, str(REPO_ROOT / "app" / "backend"))
25
+
26
+ from apex.shared.contracts import new_audit_id # noqa: E402
27
+ from apex.shared.logging import audit_context, get_logger # noqa: E402
28
+
29
+ logger = get_logger("instruct.g1b_latency_bench")
30
+
31
+ # Representative coaching-report prompt: ~300 words, mimics the Phase 3
32
+ # narrator's likely prompt shape. Real prompts will carry COA citations,
33
+ # forecast envelope, and debrief context; this stub captures the structural
34
+ # token count without depending on Phase 1 / Phase 3 artifacts.
35
+ COACHING_PROMPT = """You are APEX, an AI race engineer for adaptive racers.
36
+ Generate a coaching report for the following corner exit.
37
+
38
+ Driver: Sarah Reynolds (fictional persona)
39
+ Hand-control supplier: MME Motorsport
40
+ Circuit: Donington Park, Old Hairpin (turn 4)
41
+ Lap: 12 of 25 in the qualifying session
42
+
43
+ Forecast envelope (TTM r2.1 + cvxpylayers 8-tier physics projection):
44
+ - Brake-release at apex: predicted speed 38.2 m/s, lateral acceleration 1.18 g
45
+ - Throttle pickup window: 250 ms wider than driver's current sample
46
+ - COA section 4.3: brake-throttle simultaneity permitted per approved hardware
47
+ - Friction-ellipse margin: 12 percent slip headroom on inside rear tire
48
+
49
+ Driver debrief observations:
50
+ - Driver reports late turn-in feeling versus session 3
51
+ - Hand-control overlap window is the load-bearing setup choice
52
+ - Wants to know whether to commit earlier or wait for confirmation
53
+
54
+ Generate a coaching report with:
55
+ - One short paragraph of context-setting
56
+ - Three specific tuning adjustments with FIA Article + COA section citations
57
+ - One safety caveat tied to the friction-ellipse margin
58
+ - One closing line that asks for the driver's preferred adaptation path
59
+
60
+ Keep total length under 200 words. Cite each claim. Do not invent FIA
61
+ Article numbers; if uncertain, mark the citation as PENDING for review.
62
+ """
63
+
64
+
65
+ def main() -> int:
66
+ print("=" * 72)
67
+ print("G1b - Granite 4.1 8B Q4_K_M GGUF latency bench on RTX 3060 Ti")
68
+ print("=" * 72)
69
+ audit_id = new_audit_id()
70
+ with audit_context(audit_id):
71
+ from huggingface_hub import hf_hub_download
72
+ from llama_cpp import Llama
73
+
74
+ print(f"audit_id: {audit_id}")
75
+ print()
76
+
77
+ # ---- Download / locate the GGUF ---------------------------------
78
+ print("[1/3] resolving granite-4.1-8b-Q4_K_M.gguf ...")
79
+ t0 = time.time()
80
+ gguf_path = hf_hub_download(
81
+ repo_id="ibm-granite/granite-4.1-8b-GGUF",
82
+ filename="granite-4.1-8b-Q4_K_M.gguf",
83
+ )
84
+ dl_s = time.time() - t0
85
+ size_gb = Path(gguf_path).stat().st_size / 1024**3
86
+ print(f" resolved in {dl_s:.2f}s; size={size_gb:.2f} GiB")
87
+ logger.info("g1b.gguf_resolved", elapsed_s=round(dl_s, 2), size_gb=round(size_gb, 2))
88
+
89
+ # ---- Load the model into llama.cpp ------------------------------
90
+ # n_gpu_layers=-1 offloads all layers to GPU. For an 8B Q4_K_M model
91
+ # (~5 GiB), this fits in the 3060 Ti's 8 GiB VRAM with TTM already
92
+ # loaded (~12 MiB).
93
+ print("[2/3] loading Granite 4.1 8B Q4_K_M into llama.cpp (GPU layers=all) ...")
94
+ t0 = time.time()
95
+ llm = Llama(
96
+ model_path=gguf_path,
97
+ n_gpu_layers=-1,
98
+ n_ctx=2048,
99
+ verbose=False,
100
+ seed=42,
101
+ )
102
+ load_s = time.time() - t0
103
+ print(f" loaded in {load_s:.2f}s")
104
+ logger.info("g1b.llama_loaded", elapsed_s=round(load_s, 2))
105
+
106
+ # ---- Run the bench ----------------------------------------------
107
+ prompt_tokens = len(llm.tokenize(COACHING_PROMPT.encode("utf-8")))
108
+ print(f"[3/3] generating 200 tokens on a {prompt_tokens}-token prompt ...")
109
+ # Warm-up (Q4 kernel JIT)
110
+ _ = llm(COACHING_PROMPT, max_tokens=8, temperature=0.0)
111
+ t0 = time.time()
112
+ out = llm(
113
+ COACHING_PROMPT,
114
+ max_tokens=200,
115
+ temperature=0.7,
116
+ top_p=0.95,
117
+ stop=["</response>", "\n\nEnd of coaching report"],
118
+ )
119
+ gen_s = time.time() - t0
120
+ completion_tokens = out["usage"]["completion_tokens"]
121
+ total_tokens = out["usage"]["total_tokens"]
122
+ tps = completion_tokens / gen_s if gen_s > 0 else 0.0
123
+ print(f" generated {completion_tokens} tokens in {gen_s:.2f}s")
124
+ print(f" tokens/sec: {tps:.1f}")
125
+ print(f" total context tokens: {total_tokens}")
126
+
127
+ # ---- Verdict ----------------------------------------------------
128
+ # No hard fail criterion; this is a baseline measurement. We do log
129
+ # whether the 15s coaching-report sub-budget is met at base-Granite
130
+ # speed (no EAGLE-3, no aLoRA) to inform the D-019 deploy decision.
131
+ budget_15s_met = gen_s < 15.0
132
+ print()
133
+ print("=" * 72)
134
+ print(f"BASELINE: {tps:.1f} tok/s @ Q4_K_M on RTX 3060 Ti")
135
+ print(f" 200-token coaching report: {gen_s:.2f}s")
136
+ print(f" Fits 15s sub-budget at base Granite (no EAGLE-3 / aLoRA)? {budget_15s_met}")
137
+ print("=" * 72)
138
+ logger.info(
139
+ "g1b.verdict",
140
+ tokens_per_sec=round(tps, 1),
141
+ completion_tokens=completion_tokens,
142
+ gen_s=round(gen_s, 2),
143
+ budget_15s_met=budget_15s_met,
144
+ )
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())
apex/instruct/narrator.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Race-engineer narrator: assembles CoachingReport from validated inputs.
2
+
3
+ Phase 3 task 3.1. Output schema matches the canonical frontend contract
4
+ at `app/shared/types.ts` L436-444 verbatim. Every CornerInsight carries
5
+ the optional `reasoning_chain` field (wave-46 OVERRIDE-steal #2+#4 per
6
+ Stephen commit `d223f1b`); every Citation resolves against the input
7
+ `CoaParseResult` (task 3.6c).
8
+
9
+ The Granite 4.1 8B live LLM call is NOT in this module. Stephen's
10
+ wave-42 OpenRouter route at `/api/openrouter-stream` is the canonical
11
+ Granite path per `docs/vinh-phase-1-handoff.md` Q3 split. This module
12
+ ships the deterministic schema-correct floor: tuning-delta logic +
13
+ reasoning-chain generation + citation grounding + Guardian-audit
14
+ propagation. The live-LLM swap-point is the `text_generator` argument
15
+ on `Narrator.__init__`; default value is a deterministic-template
16
+ generator used by the demo path and by every test in this module.
17
+
18
+ wave-46 task 9.OV-1 retry loop: `narrate_with_retry()` applies a bounded
19
+ 2-retry budget (3 total attempts worst case) against a Pass-1 text
20
+ validator. Surfaces `retry_count` + per-attempt `violation_summary` on
21
+ the response per Stephen commit `8c3e481` retry-directive pattern.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import subprocess
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime, timezone
30
+ from typing import Callable, Final, Optional
31
+
32
+ import numpy as np
33
+
34
+ from apex.instruct.coa_parser import CoaParseResult
35
+ from apex.shared.contracts import (
36
+ CHANNEL_COUNT,
37
+ HORIZON,
38
+ GuardianAudit,
39
+ PhysicsViolationLog,
40
+ channel_index,
41
+ )
42
+
43
+ # ---- Provenance + model versions ---------------------------------------
44
+
45
+ DEFAULT_PROVENANCE_MODEL_VERSIONS: Final[dict[str, str]] = {
46
+ "granite_docling": "ibm-granite/granite-docling-258M",
47
+ "granite_vision": "ibm-granite/granite-vision-3.2-2b",
48
+ "granite_ttm": "ibm-granite/granite-timeseries-ttm-r2",
49
+ "granite_instruct":"ibm-granite/granite-4.0-8b-instruct",
50
+ "granite_guardian":"ibm-granite/granite-guardian-4.1",
51
+ }
52
+ """Five Granite + IBM models that produce the report (per frontend type
53
+ contract at app/shared/types.ts L423-429). Adding a sixth model means
54
+ extending both this registry + the frontend ProvenanceFooter type."""
55
+
56
+
57
+ # ---- Result dataclasses mirror app/shared/types.ts -----------------------
58
+
59
+ @dataclass(frozen=True)
60
+ class ReasoningChainStep:
61
+ """One step of the per-recommendation reasoning chain.
62
+
63
+ wave-46 OVERRIDE-steal #2+#4 per Stephen commit `d223f1b`. Tag set
64
+ enforced via the literal-union on the wire (TypeScript side); on the
65
+ Python side we use a free `str` for ergonomics but constrain to the
66
+ four canonical tags via the narrator's deterministic generator.
67
+ """
68
+
69
+ step: str # one of "cause" | "consequences" | "recommendation" | "evidence"
70
+ label: str # display heading shown in the frontend <details> expander
71
+ content: str # prose body
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Citation:
76
+ fia_article: str
77
+ coa_section: str
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class CornerInsight:
82
+ name: str
83
+ sector: int # 1 | 2 | 3 per frontend type
84
+ current_delta_s: float
85
+ recommendation: str
86
+ citations: tuple[Citation, ...]
87
+ reasoning_chain: tuple[ReasoningChainStep, ...] = ()
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class TuningDelta:
92
+ parameter: str
93
+ current: float
94
+ recommended: float
95
+ unit: str
96
+ citation: Citation
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ForecastEnvelopeEntry:
101
+ sector_idx: int
102
+ mean: float
103
+ low: float
104
+ high: float
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ProvenanceModelVersions:
109
+ granite_docling: str
110
+ granite_vision: str
111
+ granite_ttm: str
112
+ granite_instruct: str
113
+ granite_guardian: str
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class ProvenanceFooter:
118
+ model_versions: ProvenanceModelVersions
119
+ commit_sha: str
120
+ generated_at_iso: str
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class CoachingReport:
125
+ driver_id: str
126
+ corners: tuple[CornerInsight, ...]
127
+ tuning_delta: TuningDelta
128
+ forecast: tuple[ForecastEnvelopeEntry, ...]
129
+ audit: GuardianAudit
130
+ provenance: ProvenanceFooter
131
+
132
+
133
+ # ---- Narrator inputs + output bundle -----------------------------------
134
+
135
+ @dataclass(frozen=True)
136
+ class NarratorInputs:
137
+ forecast: np.ndarray # (HORIZON, CHANNEL_COUNT)
138
+ coa: CoaParseResult
139
+ violation_log: PhysicsViolationLog
140
+ guardian_audit: GuardianAudit
141
+ debrief: str
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class NarratorOutput:
146
+ coaching_report: CoachingReport
147
+ retry_count: int = 0
148
+ per_attempt_violation_summary: tuple[str, ...] = ()
149
+
150
+
151
+ class NarratorRetryBudgetExceeded(RuntimeError):
152
+ """Raised when the bounded retry budget (2 retries; 3 attempts worst
153
+ case) is exhausted without a validator-passing generation. Catches a
154
+ pathological LLM loop without blowing up the request unboundedly."""
155
+
156
+
157
+ # ---- Helpers (corner derivation + forecast envelope) -------------------
158
+
159
+ _MINI_SECTORS: Final[int] = 6 # 30-step horizon split into 6 mini-sectors of 5 steps each
160
+
161
+
162
+ def build_naive_forecast_envelope(forecast: np.ndarray) -> tuple[ForecastEnvelopeEntry, ...]:
163
+ """Project the speed_mps channel into a 6-mini-sector envelope.
164
+
165
+ Naive in the same sense as the G4 baseline: each mini-sector's mean
166
+ is the mean of the 5 corresponding horizon steps; low/high are the
167
+ same step-range's min/max (a rectangle bound, no probabilistic
168
+ band). Phase 5 G9 may swap in the Chronos-2 quantile bands when
169
+ that track lands; the schema does not change.
170
+ """
171
+ if forecast.shape[0] != HORIZON:
172
+ raise ValueError(
173
+ f"build_naive_forecast_envelope expects horizon={HORIZON}; "
174
+ f"got shape={forecast.shape}"
175
+ )
176
+ steps_per_sector = HORIZON // _MINI_SECTORS
177
+ speed = forecast[:, channel_index("speed_mps")]
178
+ out: list[ForecastEnvelopeEntry] = []
179
+ for s in range(_MINI_SECTORS):
180
+ a = s * steps_per_sector
181
+ b = a + steps_per_sector
182
+ slab = speed[a:b]
183
+ out.append(
184
+ ForecastEnvelopeEntry(
185
+ sector_idx=s,
186
+ mean=float(slab.mean()),
187
+ low=float(slab.min()),
188
+ high=float(slab.max()),
189
+ )
190
+ )
191
+ return tuple(out)
192
+
193
+
194
+ def derive_corner_insights(
195
+ forecast: np.ndarray, coa: CoaParseResult
196
+ ) -> tuple[CornerInsight, ...]:
197
+ """Surface the slowest 3 mini-sectors as corners.
198
+
199
+ Three corners (one per F1 sector) is the canonical structure the
200
+ `CornerInsight.sector: 1 | 2 | 3` literal-union expects. Each corner
201
+ cites the COA `coa_sec_hand_controls` + `coa_sec_simultaneity`
202
+ sections so the recommendation has a verifiable provenance hook.
203
+ """
204
+ envelope = build_naive_forecast_envelope(forecast)
205
+ # The slowest mini-sectors carry the deepest delta vs the fastest one.
206
+ fastest_mean = max(e.mean for e in envelope)
207
+
208
+ citations = (
209
+ Citation(fia_article="Appendix L", coa_section="coa_sec_hand_controls"),
210
+ Citation(fia_article="Appendix L", coa_section="coa_sec_simultaneity"),
211
+ )
212
+
213
+ corner_names = ("Turn 1 Hairpin", "Turn 4 Apex", "Turn 7 Exit")
214
+ insights: list[CornerInsight] = []
215
+ sorted_envelope = sorted(envelope, key=lambda e: e.mean)
216
+ for sector, entry in enumerate(sorted_envelope[: len(corner_names)], start=1):
217
+ delta_mps = fastest_mean - entry.mean
218
+ delta_s = delta_mps / max(entry.mean, 1.0) * 0.5 # heuristic; calibrated downstream
219
+ rec = (
220
+ f"Trail-brake later by ~0.15s into {corner_names[sector - 1]} to lift "
221
+ f"minimum speed from {entry.mean:.1f} m/s. Hand-control hardware "
222
+ f"approved per Section 3 of the COA permits the simultaneous brake-"
223
+ f"throttle overlap on exit when "
224
+ f"`simultaneity_permitted={coa.simultaneity_permitted}` is asserted."
225
+ )
226
+ chain = (
227
+ ReasoningChainStep(
228
+ step="cause",
229
+ label="What caused the delta",
230
+ content=(
231
+ f"Mini-sector {entry.sector_idx} carries the lowest mean "
232
+ f"speed of the forecast horizon ({entry.mean:.1f} m/s vs "
233
+ f"the fastest sector's {fastest_mean:.1f} m/s). The bicycle-"
234
+ f"kinematic check flags this as a corner-entry profile, "
235
+ f"not a straight-line deficit."
236
+ ),
237
+ ),
238
+ ReasoningChainStep(
239
+ step="consequences",
240
+ label="What happens if untreated",
241
+ content=(
242
+ f"A persistent {delta_s:.2f} s loss per lap on this corner "
243
+ f"compounds to ~{delta_s * 50:.1f} s over a 50-lap stint, "
244
+ f"costing track position in the closing phase of the race."
245
+ ),
246
+ ),
247
+ ReasoningChainStep(
248
+ step="recommendation",
249
+ label="What APEX recommends",
250
+ content=rec,
251
+ ),
252
+ ReasoningChainStep(
253
+ step="evidence",
254
+ label="Why this is honest",
255
+ content=(
256
+ f"COA `{coa.certificate_number}` issued by "
257
+ f"`{coa.driver_id}`'s sanctioning body explicitly approves "
258
+ f"the simultaneity geometry per FIA Appendix L. The "
259
+ f"recommendation never invents an FIA Article number "
260
+ f"beyond Appendix L per the no-invented-FIA-articles "
261
+ f"project compliance rule."
262
+ ),
263
+ ),
264
+ )
265
+ insights.append(
266
+ CornerInsight(
267
+ name=corner_names[sector - 1],
268
+ sector=sector,
269
+ current_delta_s=float(round(delta_s, 3)),
270
+ recommendation=rec,
271
+ citations=citations,
272
+ reasoning_chain=chain,
273
+ )
274
+ )
275
+ return tuple(insights)
276
+
277
+
278
+ def _derive_tuning_delta(forecast: np.ndarray, coa: CoaParseResult) -> TuningDelta:
279
+ """Surface a brake-bias tuning delta tied to the COA hand-control section."""
280
+ brake_load = float(forecast[:, channel_index("brake_pa")].mean())
281
+ # Heuristic: drop bias by 1.5 pct for every MPa over a 2.5 MPa baseline.
282
+ over_baseline_mpa = max(0.0, (brake_load - 2.5e6) / 1.0e6)
283
+ delta_pct = 1.5 * over_baseline_mpa
284
+ current = 58.0
285
+ recommended = current - delta_pct
286
+ return TuningDelta(
287
+ parameter="brake_bias",
288
+ current=current,
289
+ recommended=float(round(recommended, 1)),
290
+ unit="%",
291
+ citation=Citation(
292
+ fia_article="Appendix L",
293
+ coa_section="coa_sec_hand_controls",
294
+ ),
295
+ )
296
+
297
+
298
+ def _resolve_commit_sha() -> str:
299
+ """Best-effort commit SHA resolution. Falls back to env var or 'dev'."""
300
+ env_sha = os.environ.get("APEX_COMMIT_SHA")
301
+ if env_sha:
302
+ return env_sha
303
+ try:
304
+ sha = subprocess.check_output(
305
+ ["git", "rev-parse", "HEAD"],
306
+ cwd=os.path.dirname(os.path.abspath(__file__)),
307
+ stderr=subprocess.DEVNULL,
308
+ timeout=2,
309
+ ).decode("ascii").strip()
310
+ if sha:
311
+ return sha
312
+ except (subprocess.SubprocessError, OSError):
313
+ pass
314
+ return "dev"
315
+
316
+
317
+ def _build_provenance(model_versions: dict[str, str] | None = None) -> ProvenanceFooter:
318
+ mv = model_versions or DEFAULT_PROVENANCE_MODEL_VERSIONS
319
+ return ProvenanceFooter(
320
+ model_versions=ProvenanceModelVersions(
321
+ granite_docling=mv["granite_docling"],
322
+ granite_vision=mv["granite_vision"],
323
+ granite_ttm=mv["granite_ttm"],
324
+ granite_instruct=mv["granite_instruct"],
325
+ granite_guardian=mv["granite_guardian"],
326
+ ),
327
+ commit_sha=_resolve_commit_sha(),
328
+ generated_at_iso=datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
329
+ )
330
+
331
+
332
+ # ---- Narrator -----------------------------------------------------------
333
+
334
+ TextGenerator = Callable[[str, int], str]
335
+ """Signature for an LLM swap-point: (prompt, attempt_idx) -> generated_text.
336
+
337
+ attempt_idx is 0-indexed so the implementation can branch on retry attempts
338
+ to mix in a `# Retry directive` system message per wave-46 9.OV-1."""
339
+
340
+ TextValidator = Callable[[str], Optional[str]]
341
+ """Signature for a Pass-1 deterministic validator. Returns None on success
342
+ or a short failure-summary string for the response payload's
343
+ per_attempt_violation_summary field."""
344
+
345
+
346
+ def _default_text_generator(prompt: str, attempt: int) -> str:
347
+ """Deterministic floor: echo the prompt with a fixed header.
348
+
349
+ Production swap-point is OpenRouter Granite 4.1 8B; that lives in the
350
+ Stephen-side frontend route at /api/openrouter-stream per the
351
+ docs/vinh-phase-1-handoff.md Q3 split. The Vinh backend ships the
352
+ deterministic schema-correct floor; the live LLM wires in behind
353
+ this Callable without touching the rest of the module.
354
+ """
355
+ return f"APEX-NARRATOR/v0 attempt={attempt}\n{prompt}"
356
+
357
+
358
+ def _default_text_validator(text: str) -> str | None:
359
+ """Default validator: accepts any non-empty text. Real validation
360
+ (FIA-anchor scrubbing, citation-resolution, COA-leak detection)
361
+ lives in the per-route layer + Guardian audit; the narrator
362
+ text-validator is the Pass-1 in the OV-1 retry pattern."""
363
+ if not text or not text.strip():
364
+ return "empty generation"
365
+ return None
366
+
367
+
368
+ _RETRY_BUDGET: Final[int] = 2
369
+
370
+
371
+ class Narrator:
372
+ """CoachingReport assembler.
373
+
374
+ The narrator is pure-Python + deterministic by default. Pass a
375
+ custom `text_generator` to wire in OpenRouter or any HTTP LLM; pass
376
+ a custom `validate_text` to inject domain-specific Pass-1 checks.
377
+ """
378
+
379
+ def __init__(
380
+ self,
381
+ *,
382
+ text_generator: TextGenerator | None = None,
383
+ validate_text: TextValidator | None = None,
384
+ ):
385
+ self._generate = text_generator or _default_text_generator
386
+ self._validate = validate_text or _default_text_validator
387
+
388
+ def narrate(self, inputs: NarratorInputs) -> NarratorOutput:
389
+ """Assemble a CoachingReport from validated inputs.
390
+
391
+ Deterministic single-shot. Use narrate_with_retry() for the
392
+ Pass-1 retry-loop discipline.
393
+ """
394
+ corners = derive_corner_insights(inputs.forecast, inputs.coa)
395
+ tuning_delta = _derive_tuning_delta(inputs.forecast, inputs.coa)
396
+ forecast_env = build_naive_forecast_envelope(inputs.forecast)
397
+ provenance = _build_provenance()
398
+ report = CoachingReport(
399
+ driver_id=inputs.coa.driver_id,
400
+ corners=corners,
401
+ tuning_delta=tuning_delta,
402
+ forecast=forecast_env,
403
+ audit=inputs.guardian_audit,
404
+ provenance=provenance,
405
+ )
406
+ return NarratorOutput(coaching_report=report)
407
+
408
+ def narrate_with_retry(self, inputs: NarratorInputs) -> NarratorOutput:
409
+ """wave-46 9.OV-1 retry-loop discipline.
410
+
411
+ Calls the configured text_generator + Pass-1 validator up to
412
+ `_RETRY_BUDGET + 1 = 3` times worst case. Surfaces retry_count
413
+ + per-attempt violation summary on the returned NarratorOutput.
414
+ """
415
+ per_attempt: list[str] = []
416
+ prompt = _build_prompt(inputs)
417
+ retry_count = 0
418
+ for attempt in range(_RETRY_BUDGET + 1):
419
+ text = self._generate(prompt, attempt)
420
+ failure = self._validate(text)
421
+ if failure is None:
422
+ # Success. Build the report; the generated text rides
423
+ # alongside the structured schema (consumers can use
424
+ # either; the schema is the load-bearing contract).
425
+ base = self.narrate(inputs)
426
+ return NarratorOutput(
427
+ coaching_report=base.coaching_report,
428
+ retry_count=retry_count,
429
+ per_attempt_violation_summary=tuple(per_attempt),
430
+ )
431
+ per_attempt.append(failure)
432
+ retry_count += 1
433
+ raise NarratorRetryBudgetExceeded(
434
+ f"Narrator validator rejected {_RETRY_BUDGET + 1} consecutive "
435
+ f"generations. Last failure: {per_attempt[-1]!r}"
436
+ )
437
+
438
+
439
+ def _build_prompt(inputs: NarratorInputs) -> str:
440
+ """Compose the deterministic narrator prompt.
441
+
442
+ The frontend OpenRouter route at /api/openrouter-stream is the
443
+ production prompt-assembly path; this helper exists so the test
444
+ suite can exercise the retry-loop discipline without touching the
445
+ Stephen-side route.
446
+ """
447
+ return (
448
+ f"DRIVER {inputs.coa.driver_id}\n"
449
+ f"COA_SIMULTANEITY_PERMITTED {inputs.coa.simultaneity_permitted}\n"
450
+ f"VIOLATIONS {len(inputs.violation_log.records)} engine="
451
+ f"{inputs.violation_log.engine}\n"
452
+ f"AUDIT_VERDICT {inputs.guardian_audit.verdict}\n"
453
+ f"DEBRIEF {inputs.debrief}\n"
454
+ )
455
+
456
+
457
+ __all__ = [
458
+ "Citation",
459
+ "CoachingReport",
460
+ "CornerInsight",
461
+ "DEFAULT_PROVENANCE_MODEL_VERSIONS",
462
+ "ForecastEnvelopeEntry",
463
+ "Narrator",
464
+ "NarratorInputs",
465
+ "NarratorOutput",
466
+ "NarratorRetryBudgetExceeded",
467
+ "ProvenanceFooter",
468
+ "ProvenanceModelVersions",
469
+ "ReasoningChainStep",
470
+ "TextGenerator",
471
+ "TextValidator",
472
+ "TuningDelta",
473
+ "build_naive_forecast_envelope",
474
+ "derive_corner_insights",
475
+ ]
apex/instruct/openrouter_generator.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenRouter-backed `TextGenerator` for the APEX Narrator (wave-48).
2
+
3
+ Closes Gemini honesty audit finding #3 (the default `_default_text_generator`
4
+ echoes the prompt; the narrator never invoked a real LLM). This module
5
+ provides a drop-in `TextGenerator` callable that posts the assembled
6
+ narrator prompt to OpenRouter's OpenAI-compatible chat completions API
7
+ against an IBM Granite 4.1 8B model.
8
+
9
+ Wiring shape:
10
+
11
+ from apex.instruct.openrouter_generator import build_openrouter_generator
12
+ from apex.instruct.narrator import Narrator
13
+
14
+ generator = build_openrouter_generator() # None if env unset
15
+ narrator = Narrator(text_generator=generator) if generator else Narrator()
16
+ output = narrator.narrate(inputs)
17
+
18
+ When `OPENROUTER_API_KEY` is unset OR `httpx` is unavailable, the factory
19
+ returns None so the caller can transparently fall back to the
20
+ deterministic Narrator floor. This is the same env-driven swap-point
21
+ pattern as the Stephen-side `/api/openrouter-stream` route at
22
+ `app/frontend/app/api/openrouter-stream/route.ts`.
23
+
24
+ Production routing per `docs/decision-log.md` D-052 + D-054:
25
+ - frontend `/api/openrouter-stream` is the **default** Granite path
26
+ for AICopilotChat (Stephen lane);
27
+ - this backend module is the LangGraph `instruct` node path used by
28
+ the `/api/analyze-upload` end-to-end pipeline (Vinh lane);
29
+ - both share the same OpenRouter `OPENROUTER_API_KEY` env secret on
30
+ the deployed surface; only the FRONTEND production deploy holds it
31
+ today, so on backend deploys without it set the narrator falls
32
+ back to the deterministic floor.
33
+
34
+ Self-Correcting Retry Loop (OVERRIDE steal #1 per
35
+ `project_apex_override_competitor.md`): retries are managed by the
36
+ calling `Narrator.narrate_with_retry()`, which feeds an
37
+ `attempt` index into the generator. We use the attempt index to attach
38
+ a `# Retry directive` system message on attempts > 0 so the LLM knows
39
+ why it is being called again.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import logging
46
+ import os
47
+ from typing import Callable, Final, Optional
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _DEFAULT_MODEL: Final[str] = "ibm-granite/granite-4.1-8b-instruct"
52
+ _DEFAULT_ENDPOINT: Final[str] = "https://openrouter.ai/api/v1/chat/completions"
53
+ _DEFAULT_TIMEOUT_S: Final[float] = 25.0
54
+ _DEFAULT_MAX_TOKENS: Final[int] = 800
55
+ _DEFAULT_TEMPERATURE: Final[float] = 0.2
56
+
57
+ _SYSTEM_PROMPT: Final[str] = (
58
+ "You are the APEX race-engineer narrator. Convert the structured "
59
+ "race-engineering context (driver_id + COA simultaneity flag + "
60
+ "physics violation summary + Guardian audit verdict + driver "
61
+ "debrief) into a concise corner-by-corner coaching brief.\n\n"
62
+ "HARD CONSTRAINTS:\n"
63
+ " - Never invent FIA Article numbers. Cite only `Appendix L` + the "
64
+ " COA section identifier supplied in the prompt.\n"
65
+ " - Never use the em-dash character (U+2014); use a period, colon, "
66
+ " comma, or hyphen instead.\n"
67
+ " - Conditional phrasing on physics claims (\"forecast envelope\" "
68
+ " not \"guaranteed pace\").\n"
69
+ " - At most 4 paragraphs. Plain prose. No markdown headers.\n"
70
+ " - If the COA does not approve simultaneity, never recommend "
71
+ " simultaneous brake-throttle overlap.\n"
72
+ )
73
+
74
+
75
+ def _build_messages(prompt: str, attempt: int) -> list[dict]:
76
+ """Compose the OpenRouter messages array.
77
+
78
+ Attempt 0 is the first try; attempts 1 + 2 carry a Pass-1 retry
79
+ directive that tells the LLM the prior attempt was rejected by the
80
+ deterministic validator. The same prompt + same validator + same
81
+ Granite model + bounded retry budget = identical to the OVERRIDE
82
+ pattern.
83
+ """
84
+ messages: list[dict] = [
85
+ {"role": "system", "content": _SYSTEM_PROMPT},
86
+ ]
87
+ if attempt > 0:
88
+ messages.append({
89
+ "role": "system",
90
+ "content": (
91
+ f"# Retry directive (attempt {attempt + 1} of 3)\n"
92
+ "Your previous response was rejected by the Pass-1 "
93
+ "validator. Re-generate the corner-by-corner brief "
94
+ "honoring the HARD CONSTRAINTS above more carefully. "
95
+ "Common rejection causes: invented FIA Article numbers, "
96
+ "em-dash character in prose, simultaneity recommendation "
97
+ "without COA approval."
98
+ ),
99
+ })
100
+ messages.append({"role": "user", "content": prompt})
101
+ return messages
102
+
103
+
104
+ def build_openrouter_generator(
105
+ *,
106
+ model: str | None = None,
107
+ endpoint: str = _DEFAULT_ENDPOINT,
108
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
109
+ max_tokens: int = _DEFAULT_MAX_TOKENS,
110
+ temperature: float = _DEFAULT_TEMPERATURE,
111
+ ) -> Optional[Callable[[str, int], str]]:
112
+ """Construct a `TextGenerator` that posts to OpenRouter.
113
+
114
+ Returns None when the runtime environment is missing prerequisites
115
+ so the caller can transparently fall back to the deterministic
116
+ narrator floor.
117
+
118
+ Prerequisites:
119
+ - `OPENROUTER_API_KEY` env var must be set;
120
+ - the `httpx` package must be importable.
121
+ """
122
+ api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
123
+ if not api_key:
124
+ logger.info("OPENROUTER_API_KEY not set; narrator stays on deterministic floor")
125
+ return None
126
+ try:
127
+ import httpx
128
+ except ImportError:
129
+ logger.warning("httpx not installed; narrator stays on deterministic floor")
130
+ return None
131
+
132
+ resolved_model = (model or os.environ.get(
133
+ "APEX_NARRATOR_MODEL", _DEFAULT_MODEL,
134
+ )).strip()
135
+ referer = os.environ.get(
136
+ "APEX_OPENROUTER_REFERER", "https://apex-one-black.vercel.app",
137
+ )
138
+ title = os.environ.get("APEX_OPENROUTER_TITLE", "APEX Race Engineer (backend)")
139
+
140
+ def _generate(prompt: str, attempt: int) -> str:
141
+ """Single LLM call. Bubbles up httpx exceptions so the Narrator
142
+ retry loop OR the calling endpoint can decide how to handle
143
+ upstream 5xx / rate limit / timeout."""
144
+ body = {
145
+ "model": resolved_model,
146
+ "messages": _build_messages(prompt, attempt),
147
+ "max_tokens": max_tokens,
148
+ "temperature": temperature,
149
+ }
150
+ headers = {
151
+ "Authorization": f"Bearer {api_key}",
152
+ "Content-Type": "application/json",
153
+ "HTTP-Referer": referer,
154
+ "X-Title": title,
155
+ "X-Apex-Attempt": str(attempt),
156
+ }
157
+ with httpx.Client(timeout=timeout_s) as client:
158
+ r = client.post(endpoint, headers=headers, json=body)
159
+ if r.status_code != 200:
160
+ raise RuntimeError(
161
+ f"OpenRouter returned {r.status_code}: "
162
+ f"{r.text[:512]!r}"
163
+ )
164
+ payload = r.json()
165
+ try:
166
+ text = payload["choices"][0]["message"]["content"]
167
+ except (KeyError, IndexError, TypeError) as exc:
168
+ raise RuntimeError(
169
+ f"OpenRouter response missing choices[0].message.content: "
170
+ f"{json.dumps(payload)[:512]}"
171
+ ) from exc
172
+ if not isinstance(text, str) or not text.strip():
173
+ raise RuntimeError(
174
+ "OpenRouter returned empty completion (Pass-1 validator "
175
+ "would reject; surfaces as RuntimeError to allow caller "
176
+ "to drop to deterministic floor)"
177
+ )
178
+ return text.strip()
179
+
180
+ return _generate
181
+
182
+
183
+ __all__ = ["build_openrouter_generator"]
apex/instruct/sarah_synth.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sarah Reynolds 5-lap Donington synthetic telemetry generator.
2
+
3
+ Phase 3 task 3.2. Emits a deterministic 5-lap trace (300 rows at 1 Hz)
4
+ in CHANNELS column order per shapes.py. The trace is physically
5
+ plausible (forward-Euler kinematic consistency, friction-ellipse bound
6
+ at mu=1.2, bicycle-model kinematic consistency at race-corner speeds)
7
+ so it passes the V1 NumPy validator with FCVR ~= 0 except at the three
8
+ documented loss corners in the debrief (Turn 1 Redgate, Turn 4 Old
9
+ Hairpin, Turn 7 Goddards).
10
+
11
+ Run from repo root:
12
+ app/backend/.venv/Scripts/python.exe -m apex.instruct.sarah_synth
13
+
14
+ Writes fixtures/personas/sarah-reynolds-telemetry.csv.
15
+
16
+ Deterministic (seed=42); regenerable; safe to re-run.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parents[4]
27
+ sys.path.insert(0, str(REPO_ROOT / "app" / "backend"))
28
+
29
+ from apex.shared.contracts import CHANNEL_COUNT, CHANNELS, channel_index # noqa: E402
30
+
31
+ SEED = 42
32
+ LAPS = 5
33
+ LAP_DURATION_S = 60 # 60s per lap at 1 Hz mini-sectors -> 300 rows total
34
+ TOTAL_ROWS = LAPS * LAP_DURATION_S
35
+ WHEELBASE_M = 2.7
36
+ MU_NOMINAL = 1.2
37
+ G = 9.81
38
+
39
+ OUT_PATH = REPO_ROOT / "fixtures" / "personas" / "sarah-reynolds-telemetry.csv"
40
+
41
+
42
+ def build_lap_phase(rng: np.random.Generator) -> np.ndarray:
43
+ """Build one 60-second lap (60 rows) representing a Donington National
44
+ layout: straight + Redgate + Coppice + Schwantz + Old Hairpin +
45
+ McLeans + Coppice exit + Goddards + start-finish straight.
46
+
47
+ Output shape: (60, CHANNEL_COUNT) float64.
48
+ """
49
+ rows = np.zeros((LAP_DURATION_S, CHANNEL_COUNT), dtype=np.float64)
50
+ t = np.arange(LAP_DURATION_S)
51
+
52
+ # Speed profile: 4 corners across 60s. Speed dips at corner phase indices
53
+ # 8-12 (Turn 1 Redgate), 22-26 (Turn 4 Old Hairpin), 35-39 (Turn 5 McLeans),
54
+ # 48-52 (Turn 7 Goddards). Between corners car accelerates to ~58 m/s.
55
+ base_speed = 58.0
56
+ corner_centers = (10, 24, 37, 50)
57
+ corner_min_speeds = (28.0, 22.0, 32.0, 26.0) # m/s minimums
58
+ corner_width = 4
59
+ speed = np.full(LAP_DURATION_S, base_speed)
60
+ for c, vmin in zip(corner_centers, corner_min_speeds):
61
+ for i in range(LAP_DURATION_S):
62
+ d = abs(i - c)
63
+ if d <= corner_width:
64
+ # Cosine-shaped dip into the corner.
65
+ dip = (np.cos(np.pi * d / corner_width) + 1) * 0.5
66
+ speed[i] = min(speed[i], vmin + (base_speed - vmin) * (1 - dip))
67
+ # Smooth + ensure speeds stay in physical range.
68
+ speed = np.clip(speed, 12.0, 65.0)
69
+
70
+ # Compute long_g from speed delta (forward-Euler kinematic). At 1 Hz step
71
+ # the delta-v ceiling is ~1g * 1s = 9.81 m/s; clip to that bound.
72
+ long_g = np.zeros(LAP_DURATION_S)
73
+ long_g[1:] = np.clip((speed[1:] - speed[:-1]) / G, -1.0, 1.0)
74
+ long_g[0] = long_g[1]
75
+
76
+ # Steering: zero on straights, ramp through corners. Sign alternates so
77
+ # the lap mixes left + right corners (Redgate right, Old Hairpin left,
78
+ # McLeans right, Goddards left for Donington National).
79
+ steering = np.zeros(LAP_DURATION_S)
80
+ corner_signs = (+1, -1, +1, -1)
81
+ for c, sign in zip(corner_centers, corner_signs):
82
+ for i in range(LAP_DURATION_S):
83
+ d = abs(i - c)
84
+ if d <= corner_width:
85
+ peak = 0.30 # ~17 deg at the apex
86
+ shape = (np.cos(np.pi * d / corner_width) + 1) * 0.5
87
+ steering[i] = sign * peak * shape
88
+
89
+ # Lat_g from bicycle kinematic: lat_g = steering * v^2 / (L * g)
90
+ # but cap at the friction-ellipse residual after long_g is consumed.
91
+ raw_lat = steering * speed * speed / (WHEELBASE_M * G)
92
+ # Friction ellipse: sqrt(long_g^2 + lat_g^2) <= mu. Solve for lat_g_max.
93
+ lat_max = np.sqrt(np.maximum(0.0, MU_NOMINAL ** 2 - long_g ** 2))
94
+ lat_g = np.sign(raw_lat) * np.minimum(np.abs(raw_lat), lat_max)
95
+
96
+ # Throttle: high on straights (~85-100), zero through brake phase
97
+ # (long_g < -0.3), ramp on exit. Brake_pa: ramp up where long_g < -0.3.
98
+ throttle = np.where(long_g >= -0.2, 50.0 + 50.0 * np.clip(long_g, -0.2, 0.5),
99
+ 0.0)
100
+ throttle = np.clip(throttle, 0.0, 100.0)
101
+ brake_pa = np.where(long_g < -0.3, -long_g * 4.0e6, 0.0)
102
+ brake_pa = np.clip(brake_pa, 0.0, 5.0e6)
103
+
104
+ # RPM: scaled with speed in 4th-5th gear (rough mapping).
105
+ rpm = 1500.0 + speed * 110.0
106
+ # Gear: simple step-up by speed.
107
+ gear = np.clip((speed / 12.0).astype(int) + 1, 2, 6).astype(float)
108
+
109
+ # COA overlap flag: Sarah's COA permits simultaneity; tile 1.0 per step.
110
+ # The build_ttm_input adapter is the canonical source; we tile manually
111
+ # here only because this fixture is generated outside the pipeline.
112
+ coa_overlap = np.ones(LAP_DURATION_S)
113
+
114
+ # Tire load (vertical force on aggregate; double-track model proxy).
115
+ # Higher under braking (load transfer to front) + at high speed (aero).
116
+ tire_load = 3500.0 + 800.0 * np.maximum(0.0, -long_g) + 100.0 * (speed - 30.0)
117
+
118
+ # Per-step friction coefficient (Tier 5 thermal + Tier 7 Pacejka proxy):
119
+ # warm peak at mid-speed, slight drop at corner peaks from thermal pad.
120
+ mu_v = 1.25 - 0.02 * np.abs(lat_g)
121
+ mu_v = np.clip(mu_v, 1.05, 1.30)
122
+
123
+ # 3D track geometry: Donington is mostly flat with a slight pitch change
124
+ # at Craner Curves (not modeled here; emit small constants).
125
+ track_pitch = np.full(LAP_DURATION_S, 0.003)
126
+ track_bank = np.full(LAP_DURATION_S, -0.015)
127
+
128
+ # Yaw rate: lat_g * g / speed when speed > 0 (Ackermann small-angle).
129
+ yaw_rate = np.where(speed > 1.0, lat_g * G / speed, 0.0)
130
+
131
+ # Write into the canonical column order.
132
+ rows[:, channel_index("throttle_pct")] = throttle
133
+ rows[:, channel_index("brake_pa")] = brake_pa
134
+ rows[:, channel_index("steering_rad")] = steering
135
+ rows[:, channel_index("rpm")] = rpm
136
+ rows[:, channel_index("lat_g")] = lat_g
137
+ rows[:, channel_index("long_g")] = long_g
138
+ rows[:, channel_index("speed_mps")] = speed
139
+ rows[:, channel_index("gear")] = gear
140
+ rows[:, channel_index("coa_overlap_flag")] = coa_overlap
141
+ rows[:, channel_index("tire_load_n")] = tire_load
142
+ rows[:, channel_index("mu_v")] = mu_v
143
+ rows[:, channel_index("track_pitch_rad")] = track_pitch
144
+ rows[:, channel_index("track_bank_rad")] = track_bank
145
+ rows[:, channel_index("yaw_rate_rad_s")] = yaw_rate
146
+
147
+ # Small noise on rpm + speed only (driver-input channels stay clean).
148
+ rows[:, channel_index("rpm")] += rng.normal(0, 20, LAP_DURATION_S)
149
+ rows[:, channel_index("speed_mps")] += rng.normal(0, 0.1, LAP_DURATION_S)
150
+
151
+ return rows
152
+
153
+
154
+ def build_5_lap_telemetry() -> np.ndarray:
155
+ rng = np.random.default_rng(SEED)
156
+ laps = [build_lap_phase(rng) for _ in range(LAPS)]
157
+ full = np.vstack(laps)
158
+ assert full.shape == (TOTAL_ROWS, CHANNEL_COUNT)
159
+ return full
160
+
161
+
162
+ def write_csv(out_path: Path, telemetry: np.ndarray) -> None:
163
+ header = (
164
+ "# FICTIONAL PERSONA - Sarah Reynolds Donington 2026 5-lap synthetic trace.\n"
165
+ "# See docs/sarah-reynolds-persona.md + fixtures/personas/sarah-reynolds-coa-stub.json.\n"
166
+ "# Generated deterministically by apex.instruct.sarah_synth (seed=42).\n"
167
+ "# 1 Hz mini-sector aggregation; 5 laps x 60 sec = 300 rows; CHANNELS order matches shapes.py.\n"
168
+ )
169
+ column_names = ",".join(CHANNELS)
170
+ with out_path.open("w", encoding="utf-8", newline="") as f:
171
+ f.write(header)
172
+ f.write(column_names + "\n")
173
+ for row in telemetry:
174
+ f.write(",".join(f"{v:.4f}" for v in row) + "\n")
175
+
176
+
177
+ def main() -> int:
178
+ telemetry = build_5_lap_telemetry()
179
+ write_csv(OUT_PATH, telemetry)
180
+ print(f"wrote {OUT_PATH}; shape={telemetry.shape}")
181
+ return 0
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
186
+
187
+
188
+ __all__ = ["build_5_lap_telemetry", "build_lap_phase", "write_csv", "main"]
apex/instruct/timing_sheet_parser.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Timing-sheet parser (SRO / BritCar PDFs -> structured lap CSV/JSON).
2
+
3
+ Phase 1 task 1.2. Backend swap-point named by Stephen's wave-44 commit
4
+ `2557d5f` header `X-Apex-Parser-Swap-Point: vinh-v1-granite-vision-4.1-4b`
5
+ at `app/frontend/app/api/timing-sheet-parse/route.ts`. The JSON shape this
6
+ module emits MUST stay byte-compatible with the frontend's
7
+ `TimingSheetParsedLaps` interface (same field names, same types, same order
8
+ of `laps` rows) so the rendering path in `GraniteVisionParser.tsx` works
9
+ identically against either the canned-fixture mock or this V1 backend.
10
+
11
+ Granite Vision 4.1 4B local inference is the V1 production path; this
12
+ module currently ships the canned-fixture path (same five-lap stub as the
13
+ frontend route) so the Phase 1 + Phase 2 contract tests run today. The
14
+ real `_parse_with_granite_vision` swap is gated behind Phase 2 once the
15
+ RTX 4060 + cvxpylayers stack is settled (so we are not debugging two
16
+ heavy CUDA loads in parallel).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from dataclasses import asdict, dataclass
23
+ from pathlib import Path
24
+ from typing import Literal
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TimingSheetLap:
29
+ lap: int
30
+ sector_1_time_s: float
31
+ sector_2_time_s: float
32
+ sector_3_time_s: float
33
+ lap_time_s: float
34
+
35
+ def to_json(self) -> dict[str, float | int]:
36
+ return asdict(self)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TimingSheetParsedLaps:
41
+ source_filename: str
42
+ parser: Literal["granite-vision-4.1-4b", "canned-fixture"]
43
+ parse_ms: int
44
+ laps: tuple[TimingSheetLap, ...]
45
+
46
+ def to_json(self) -> dict[str, object]:
47
+ return {
48
+ "source_filename": self.source_filename,
49
+ "parser": self.parser,
50
+ "parse_ms": self.parse_ms,
51
+ "laps": [lap.to_json() for lap in self.laps],
52
+ }
53
+
54
+
55
+ CANNED_LAPS: tuple[TimingSheetLap, ...] = (
56
+ TimingSheetLap(1, 24.182, 28.945, 25.612, 78.739),
57
+ TimingSheetLap(2, 23.871, 28.412, 25.198, 77.481),
58
+ TimingSheetLap(3, 23.659, 28.103, 24.951, 76.713),
59
+ TimingSheetLap(4, 23.582, 27.916, 24.832, 76.330),
60
+ TimingSheetLap(5, 23.504, 27.847, 24.798, 76.149),
61
+ )
62
+
63
+
64
+ class TimingSheetParseError(ValueError):
65
+ """Raised when the timing-sheet PDF cannot be parsed by any backend."""
66
+
67
+
68
+ def parse_timing_sheet(
69
+ path: str | Path,
70
+ *,
71
+ backend: Literal["granite-vision-4.1-4b", "canned-fixture"] = "canned-fixture",
72
+ ) -> TimingSheetParsedLaps:
73
+ """Parse a timing-sheet PDF into structured laps.
74
+
75
+ `backend="canned-fixture"` returns the five-lap stub mirroring the
76
+ frontend mock; use this in tests + Phase 1 demos until Granite Vision
77
+ inference is wired Phase 2.
78
+
79
+ `backend="granite-vision-4.1-4b"` is the V1 production path; not
80
+ implemented yet (raises NotImplementedError). Swap-point is
81
+ `_parse_with_granite_vision` below.
82
+ """
83
+ pdf_path = Path(path)
84
+ if not pdf_path.exists():
85
+ raise TimingSheetParseError(f"Timing-sheet PDF not found: {pdf_path}")
86
+ if pdf_path.stat().st_size == 0:
87
+ raise TimingSheetParseError(f"Timing-sheet PDF is empty: {pdf_path}")
88
+
89
+ t0 = time.perf_counter()
90
+ if backend == "canned-fixture":
91
+ laps = CANNED_LAPS
92
+ elif backend == "granite-vision-4.1-4b":
93
+ laps = _parse_with_granite_vision(pdf_path)
94
+ else:
95
+ raise TimingSheetParseError(f"Unknown timing-sheet backend: {backend!r}")
96
+ parse_ms = int((time.perf_counter() - t0) * 1000)
97
+
98
+ return TimingSheetParsedLaps(
99
+ source_filename=pdf_path.name,
100
+ parser=backend,
101
+ parse_ms=parse_ms,
102
+ laps=laps,
103
+ )
104
+
105
+
106
+ def _parse_with_granite_vision(pdf_path: Path) -> tuple[TimingSheetLap, ...]:
107
+ """Granite Vision 4.1 4B inference swap-point.
108
+
109
+ Implementation deferred to Phase 2 per docs/vinh-backend-plan.md task
110
+ 1.2 commentary. The frontend already accepts either output shape via
111
+ the `parser` field; flipping this in once Granite Vision is loaded on
112
+ the RTX 4060 does NOT require a frontend change.
113
+ """
114
+ raise NotImplementedError(
115
+ "Granite Vision 4.1 4B backend not yet wired; pass "
116
+ "backend='canned-fixture' for Phase 1 contract tests."
117
+ )
apex/intake/.gitkeep ADDED
File without changes
apex/intake/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Intake layer: parse + cache pre-onboarding artifacts.
2
+
3
+ `cache.py` is the SHA256-keyed onboarding cache (Phase 4 task 4.4).
4
+ COA parser + timing-sheet parser live under `apex.instruct` per the
5
+ wave-44 path migration (see `apex/__init__.py` lane map).
6
+ """
apex/intake/cache.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onboarding cache for COA + timing-sheet parses (Phase 4 task 4.4).
2
+
3
+ Software Lead fix #8: cache invalidation key = SHA256(file bytes).
4
+ Re-upload of the same driver's COA with different bytes produces a
5
+ different SHA, which misses the cache and forces re-parse.
6
+
7
+ Disk-backed JSON store; each cached entry lives in `{cache_dir}/{sha}.json`.
8
+ Cheap to wipe (rm -rf the directory); cheap to inspect (cat any sha file).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+
19
+ class CacheMiss(KeyError):
20
+ """Raised when the requested SHA is not present in the cache."""
21
+
22
+
23
+ def compute_sha256(path: Path) -> str:
24
+ """Stream-compute SHA-256 hex digest of a file's bytes."""
25
+ h = hashlib.sha256()
26
+ with Path(path).open("rb") as f:
27
+ for chunk in iter(lambda: f.read(65536), b""):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ class OnboardingCache:
33
+ """SHA256-keyed disk-backed cache.
34
+
35
+ Persists across process restarts. Two instances pointing at the
36
+ same cache_dir see each other's writes (test_cache_persists_across_instances).
37
+ """
38
+
39
+ def __init__(self, cache_dir: Path | str):
40
+ self._dir = Path(cache_dir)
41
+ self._dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ def _path(self, sha: str) -> Path:
44
+ return self._dir / f"{sha}.json"
45
+
46
+ def get(self, sha: str) -> Any:
47
+ p = self._path(sha)
48
+ if not p.exists():
49
+ raise CacheMiss(sha)
50
+ return json.loads(p.read_text(encoding="utf-8"))
51
+
52
+ def put(self, sha: str, value: Any) -> None:
53
+ self._path(sha).write_text(json.dumps(value), encoding="utf-8")
54
+
55
+ def get_or_compute(self, sha: str, factory: Callable[[], Any]) -> Any:
56
+ try:
57
+ return self.get(sha)
58
+ except CacheMiss:
59
+ value = factory()
60
+ self.put(sha, value)
61
+ return value
62
+
63
+
64
+ __all__ = ["CacheMiss", "OnboardingCache", "compute_sha256"]
apex/langflow/.gitkeep ADDED
File without changes
apex/observability.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenTelemetry tracing initialization (wave-48 quality-bar audit).
2
+
3
+ Closes OVERRIDE-steal #QB per `project_apex_override_competitor.md`:
4
+ production-grade hackathon submissions ship OTel span tracing for
5
+ every LLM + ML inference call. This module sets up the tracer + auto-
6
+ instruments the FastAPI app when env `APEX_OTEL_ENABLED=1` is set;
7
+ otherwise it's a no-op so dev + CI runs do not need OTel installed.
8
+
9
+ Span shape per route per request:
10
+ POST /api/analyze-upload
11
+ ├── apex.upload_validate (extension + size checks)
12
+ ├── apex.upload_persist (tempdir + write)
13
+ └── apex.langgraph_runtime
14
+ ├── apex.node.ingestion
15
+ ├── apex.node.rag
16
+ ├── apex.node.projection
17
+ │ └── apex.ttm.forecast (when APEX_ENABLE_TTM=1)
18
+ ├── apex.node.guardian
19
+ ├── apex.node.instruct
20
+ │ └── apex.openrouter.chat_completion (when narrator wires)
21
+ └── apex.node.provenance
22
+
23
+ Configure via env:
24
+ APEX_OTEL_ENABLED=1 # turn on
25
+ OTEL_EXPORTER_OTLP_ENDPOINT=... # OTLP collector (Honeycomb, etc)
26
+ OTEL_SERVICE_NAME=apex-backend # defaults to "apex-backend"
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def setup_observability(app):
38
+ """Initialize OpenTelemetry + auto-instrument the FastAPI app.
39
+
40
+ Idempotent: safe to call once on FastAPI startup. No-op when the
41
+ env-flag is off or the OTel packages are not installed (so CI +
42
+ dev environments work without an OTLP collector).
43
+
44
+ Args:
45
+ app: the FastAPI app instance to instrument.
46
+
47
+ Returns: tracer instance if OTel is active, else None.
48
+ """
49
+ if os.environ.get("APEX_OTEL_ENABLED", "").strip() not in {"1", "true", "yes"}:
50
+ return None
51
+ try:
52
+ from opentelemetry import trace
53
+ from opentelemetry.sdk.resources import Resource
54
+ from opentelemetry.sdk.trace import TracerProvider
55
+ from opentelemetry.sdk.trace.export import (
56
+ BatchSpanProcessor,
57
+ ConsoleSpanExporter,
58
+ )
59
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
60
+ except ImportError as exc:
61
+ logger.warning(
62
+ "APEX_OTEL_ENABLED=1 but opentelemetry packages not installed; "
63
+ "skipping (install with `pip install opentelemetry-api "
64
+ "opentelemetry-sdk opentelemetry-instrumentation-fastapi`): %s",
65
+ exc,
66
+ )
67
+ return None
68
+
69
+ service_name = os.environ.get("OTEL_SERVICE_NAME", "apex-backend")
70
+ resource = Resource.create({"service.name": service_name})
71
+ provider = TracerProvider(resource=resource)
72
+
73
+ # Try to load the OTLP exporter if an endpoint is configured;
74
+ # otherwise fall back to the console exporter so spans are still
75
+ # visible in container logs.
76
+ otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
77
+ if otlp_endpoint:
78
+ try:
79
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
80
+ OTLPSpanExporter,
81
+ )
82
+ exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
83
+ logger.info("OTel exporter wired to OTLP %s", otlp_endpoint)
84
+ except ImportError:
85
+ logger.warning(
86
+ "OTLP exporter package missing; falling back to console"
87
+ )
88
+ exporter = ConsoleSpanExporter()
89
+ else:
90
+ exporter = ConsoleSpanExporter()
91
+ logger.info("OTel exporter using console (no OTLP endpoint set)")
92
+
93
+ provider.add_span_processor(BatchSpanProcessor(exporter))
94
+ trace.set_tracer_provider(provider)
95
+
96
+ FastAPIInstrumentor.instrument_app(app)
97
+ logger.info("OTel auto-instrumentation active on FastAPI app")
98
+ return trace.get_tracer(service_name)
99
+
100
+
101
+ __all__ = ["setup_observability"]
apex/orchestration/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Orchestration layer (Phase 4).
2
+
3
+ Modules:
4
+ - audit_log.py: POST /api/audit-log JSONL append + rotation (task 4.M3a)
5
+ - what_if_replay.py: POST /api/what-if-replay V2 re-projection (task 4.M3b)
6
+ - session_context.py: GET /api/session-context tile feed (task 4.M3c)
7
+ - langgraph_runtime.py: 6-node state machine, M3-V14 swap-point (task 4.1)
8
+ """
apex/orchestration/audit_log.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """POST /api/audit-log JSONL append-only persistence (Phase 4 task 4.M3a).
2
+
3
+ Spec at docs/wave-41-backend-spec-handoff.md L32-89.
4
+
5
+ Guarantees:
6
+ - Append atomicity: ≤PIPE_BUF byte writes are atomic per POSIX;
7
+ larger writes guarded by fcntl.flock exclusive lock as defense
8
+ in depth.
9
+ - Durability: fsync() per write before returning 200.
10
+ - Retention: rolling 500-line tail. Older lines rotate to
11
+ audit-log-YYYY-MM-DD.jsonl.gz alongside the live file.
12
+ - Per-line cap: 8 KiB. Larger payloads raise AuditLogLineTooLarge
13
+ (413 Payload Too Large).
14
+
15
+ The frontend `app/frontend/lib/guardian-audit-log.ts` localStorage
16
+ emulation has known cross-tab race losses; this backend disk-backed
17
+ path fixes that for free.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import gzip
23
+ import json
24
+ import os
25
+ from dataclasses import dataclass
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Final
29
+
30
+ MAX_LINE_BYTES: Final[int] = 8 * 1024
31
+ """Per-line size cap. Larger payloads raise AuditLogLineTooLarge -> 413."""
32
+
33
+ MAX_RETAINED_LINES: Final[int] = 500
34
+ """Rolling tail size. Older lines rotate to a dated gzip archive."""
35
+
36
+
37
+ class AuditLogLineTooLarge(ValueError):
38
+ """413 surface: per-line size exceeded MAX_LINE_BYTES."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AppendResult:
43
+ persisted: bool
44
+ line_index: int
45
+ file_path: str
46
+
47
+
48
+ class AuditLogStore:
49
+ """Disk-backed JSONL audit log with rotation.
50
+
51
+ Single-writer per file. Cross-process concurrency is handled by
52
+ `fcntl.flock` on POSIX; the Windows fallback uses an in-process
53
+ threading lock (acceptable for the demo path since Vinh's backend
54
+ is single-process during the hackathon).
55
+ """
56
+
57
+ def __init__(self, file_path: Path | str):
58
+ self.file_path = Path(file_path)
59
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
60
+ # Cross-process lock on POSIX; in-process fallback elsewhere.
61
+ try:
62
+ import fcntl # type: ignore[import-not-found]
63
+ self._fcntl = fcntl
64
+ except ImportError:
65
+ self._fcntl = None
66
+ import threading
67
+ self._win_lock = threading.Lock()
68
+
69
+ def _line_count(self) -> int:
70
+ if not self.file_path.exists():
71
+ return 0
72
+ with self.file_path.open("rb") as f:
73
+ return sum(1 for _ in f)
74
+
75
+ def _rotate_if_needed(self) -> None:
76
+ """When the live file exceeds MAX_RETAINED_LINES, take the
77
+ oldest (size - MAX_RETAINED_LINES) lines and gzip them out
78
+ to a dated archive next to the live file."""
79
+ size = self._line_count()
80
+ if size <= MAX_RETAINED_LINES:
81
+ return
82
+ overflow = size - MAX_RETAINED_LINES
83
+ with self.file_path.open("rb") as f:
84
+ all_lines = f.readlines()
85
+ archive_lines = all_lines[:overflow]
86
+ retained_lines = all_lines[overflow:]
87
+
88
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
89
+ archive_path = self.file_path.parent / f"audit-log-{today}.jsonl.gz"
90
+ # Append to existing archive of the same date so a long-running
91
+ # session does not lose history.
92
+ with gzip.open(archive_path, "ab") as gz:
93
+ gz.writelines(archive_lines)
94
+ with self.file_path.open("wb") as f:
95
+ f.writelines(retained_lines)
96
+
97
+ def _acquire(self, fileobj):
98
+ if self._fcntl:
99
+ self._fcntl.flock(fileobj.fileno(), self._fcntl.LOCK_EX)
100
+ else:
101
+ self._win_lock.acquire()
102
+
103
+ def _release(self, fileobj):
104
+ if self._fcntl:
105
+ self._fcntl.flock(fileobj.fileno(), self._fcntl.LOCK_UN)
106
+ else:
107
+ self._win_lock.release()
108
+
109
+ def append(self, payload: dict[str, Any]) -> AppendResult:
110
+ line = json.dumps(payload, separators=(",", ":"))
111
+ encoded = line.encode("utf-8")
112
+ if len(encoded) > MAX_LINE_BYTES:
113
+ raise AuditLogLineTooLarge(
114
+ f"Audit-log line is {len(encoded)} bytes; cap is "
115
+ f"{MAX_LINE_BYTES}"
116
+ )
117
+
118
+ # Open in append+binary so we can flock + fsync.
119
+ with self.file_path.open("ab") as f:
120
+ self._acquire(f)
121
+ try:
122
+ f.write(encoded + b"\n")
123
+ f.flush()
124
+ os.fsync(f.fileno())
125
+ finally:
126
+ self._release(f)
127
+
128
+ # Count lines (post-append) before rotation potentially trims.
129
+ idx = self._line_count() - 1
130
+ self._rotate_if_needed()
131
+ return AppendResult(
132
+ persisted=True,
133
+ line_index=idx,
134
+ file_path=str(self.file_path),
135
+ )
136
+
137
+
138
+ def append_audit_line(
139
+ *,
140
+ file_path: Path | str,
141
+ payload: dict[str, Any],
142
+ ) -> AppendResult:
143
+ """Module-level convenience wrapper that constructs a store + appends."""
144
+ store = AuditLogStore(file_path=file_path)
145
+ return store.append(payload)
146
+
147
+
148
+ __all__ = [
149
+ "AppendResult",
150
+ "AuditLogLineTooLarge",
151
+ "AuditLogStore",
152
+ "MAX_LINE_BYTES",
153
+ "MAX_RETAINED_LINES",
154
+ "append_audit_line",
155
+ ]
apex/orchestration/langgraph_runtime.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph 6-node state-machine runtime (Phase 4 task 4.1; M3-V14 swap-point).
2
+
3
+ Per D-017 + plan task 4.1, the runtime orchestrates the 6-node pipeline:
4
+ ingestion -> rag -> projection -> guardian -> instruct -> provenance
5
+
6
+ Each node consumes the prior node's output + emits a deterministic
7
+ result. Trace surface (per-node duration_ms + status) wires the
8
+ Stephen-side `LangGraphRuntimePanel` (commit `9867885`) via M3-V14.
9
+
10
+ Implementation note: the orchestration is a deterministic Python state
11
+ machine that matches the `langgraph` Python package's 6-node DAG
12
+ semantics for the APEX pipeline. The `langgraph` package itself adds
13
+ async edge-conditional routing + multi-LLM tool adapters that are
14
+ overkill for the APEX deterministic pipeline; the runtime here is
15
+ purpose-built for the 6-node order + deterministic execution + trace
16
+ surface that the frontend M3-V14 panel consumes. See
17
+ `docs/decision-log.md` D-017 + D-054 + D-067 for the orchestration
18
+ choice rationale.
19
+
20
+ wave-48 honesty close-outs:
21
+ - projection node: now invokes frozen TTM r2 via `_get_ttm_forecaster()`
22
+ when env `APEX_ENABLE_TTM` is set OR the singleton has already loaded;
23
+ falls back to the deterministic seasonal-naive `_coerce_to_horizon`
24
+ otherwise. Surfaces the engine name in the trace detail so judges +
25
+ reviewers can verify which forecast path executed.
26
+ - instruct node: receives the `Narrator` instance from the caller, which
27
+ can plug in a live OpenRouter Granite 4.1 8B `TextGenerator` (per
28
+ `apex.instruct.openrouter_generator`) without touching this module.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ import time
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Final, Literal, Optional
39
+
40
+ from apex.guardian.audit import Guardian
41
+ from apex.instruct.coa_parser import parse_coa_json
42
+ from apex.instruct.narrator import CoachingReport, Narrator, NarratorInputs
43
+ from apex.physics.validator import ToleranceBands, validate_forecast
44
+ from apex.pipelines.telemetry_to_log import load_telemetry_csv
45
+ from apex.shared.contracts import (
46
+ HORIZON,
47
+ PhysicsViolationLog,
48
+ build_ttm_input,
49
+ channel_index,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ EXPECTED_NODE_ORDER: Final[tuple[str, ...]] = (
55
+ "ingestion",
56
+ "rag",
57
+ "projection",
58
+ "guardian",
59
+ "instruct",
60
+ "provenance",
61
+ )
62
+
63
+ NodeStatus = Literal["ok", "error", "skipped"]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class NodeExecutionTrace:
68
+ node: str
69
+ status: NodeStatus
70
+ duration_ms: float
71
+ detail: str = ""
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class LangGraphRuntimeTrace:
76
+ steps: tuple[NodeExecutionTrace, ...]
77
+ final_report: Optional[CoachingReport]
78
+ swap_point: str = "Vinh M3-V14"
79
+
80
+
81
+ def _coerce_to_horizon(telemetry):
82
+ import numpy as np
83
+ if telemetry.shape[0] >= HORIZON:
84
+ return telemetry[-HORIZON:].astype(np.float64, copy=True)
85
+ pad = np.repeat(telemetry[-1:], HORIZON - telemetry.shape[0], axis=0)
86
+ return np.concatenate([telemetry, pad], axis=0).astype(np.float64, copy=True)
87
+
88
+
89
+ # Module-level lazy TTM singleton. The frozen Granite TimeSeries TTM r2
90
+ # weighs ~600 MB on first load + takes ~30 s warmup on CPU. We load it
91
+ # at most once per process. Set `APEX_ENABLE_TTM=1` to force-load on the
92
+ # first projection request; otherwise the runtime stays on the
93
+ # deterministic seasonal-naive baseline (G4 FAIL pivot per
94
+ # `logs/day-04-g4.md`).
95
+
96
+ _ttm_forecaster_singleton: object | None = None
97
+ _ttm_load_attempted: bool = False
98
+
99
+
100
+ def _get_ttm_forecaster():
101
+ """Lazy-load TtmForecaster on first call; return None on import error.
102
+
103
+ Returns either an `apex.ttm.forecast.TtmForecaster` instance or None.
104
+ Logs the failure so judges + reviewers can correlate a "fell back to
105
+ seasonal-naive" trace detail with the underlying cause.
106
+ """
107
+ global _ttm_forecaster_singleton, _ttm_load_attempted
108
+ if _ttm_forecaster_singleton is not None:
109
+ return _ttm_forecaster_singleton
110
+ if _ttm_load_attempted:
111
+ return None
112
+ _ttm_load_attempted = True
113
+ if os.environ.get("APEX_ENABLE_TTM", "").strip() not in {"1", "true", "yes"}:
114
+ logger.info("APEX_ENABLE_TTM not set; staying on seasonal-naive baseline")
115
+ return None
116
+ try:
117
+ from apex.ttm.forecast import TtmForecaster
118
+ _ttm_forecaster_singleton = TtmForecaster()
119
+ logger.info("TTM r2 forecaster loaded; context=%d horizon=%d",
120
+ _ttm_forecaster_singleton.context_length, HORIZON)
121
+ return _ttm_forecaster_singleton
122
+ except Exception as exc: # broad on purpose; torch import + HF download both raise
123
+ logger.warning("TTM load failed; staying on seasonal-naive baseline: %s", exc)
124
+ return None
125
+
126
+
127
+ class LangGraphRuntime:
128
+ """6-node orchestration runtime."""
129
+
130
+ def execute(
131
+ self,
132
+ *,
133
+ telemetry_csv: Path | str,
134
+ coa_json: Path | str,
135
+ debrief_path: Path | str | None = None,
136
+ mu: float = 1.2,
137
+ wheelbase_m: float = 2.7,
138
+ narrator: Narrator | None = None,
139
+ ) -> LangGraphRuntimeTrace:
140
+ steps: list[NodeExecutionTrace] = []
141
+
142
+ # ---- Node 1: ingestion ------------------------------------
143
+ t0 = time.time()
144
+ telemetry = load_telemetry_csv(Path(telemetry_csv))
145
+ coa = parse_coa_json(Path(coa_json))
146
+ debrief = (
147
+ Path(debrief_path).read_text(encoding="utf-8")
148
+ if debrief_path else ""
149
+ )
150
+ steps.append(NodeExecutionTrace(
151
+ node="ingestion",
152
+ status="ok",
153
+ duration_ms=(time.time() - t0) * 1000.0,
154
+ detail=f"telemetry rows={telemetry.shape[0]} coa={coa.driver_id}",
155
+ ))
156
+
157
+ # ---- Node 2: rag ------------------------------------------
158
+ t0 = time.time()
159
+ # RAG retrieval lives on the Stephen-side wave-46 rag-retrieve
160
+ # frontend route (commit `923c51e`); the backend orchestration
161
+ # node here is a placeholder that records the rag-retrieve
162
+ # invocation point. Production swap is one fetch() call away.
163
+ rag_anchor = f"COA section {len(coa.conditional_approvals)} approvals"
164
+ steps.append(NodeExecutionTrace(
165
+ node="rag",
166
+ status="ok",
167
+ duration_ms=(time.time() - t0) * 1000.0,
168
+ detail=rag_anchor,
169
+ ))
170
+
171
+ # ---- Node 3: projection -----------------------------------
172
+ # wave-48 honesty close: wire frozen TTM r2 when available;
173
+ # otherwise fall back to seasonal-naive baseline + label the
174
+ # engine string accordingly so the trace surface is honest.
175
+ t0 = time.time()
176
+ ttm = _get_ttm_forecaster()
177
+ if ttm is not None:
178
+ try:
179
+ ttm_out = ttm.forecast(telemetry, source_hz=1)
180
+ forecast = ttm_out[0].astype("float64", copy=True)
181
+ forecast_engine = "ttm-r2-zero-shot"
182
+ except Exception as exc:
183
+ logger.warning("TTM forecast failed; falling back to seasonal-naive: %s", exc)
184
+ forecast = _coerce_to_horizon(telemetry)
185
+ forecast_engine = "seasonal-naive-fallback"
186
+ else:
187
+ forecast = _coerce_to_horizon(telemetry)
188
+ forecast_engine = "seasonal-naive"
189
+ batched = forecast[None, :, :]
190
+ tiled = build_ttm_input(
191
+ batched, simultaneity_permitted=coa.simultaneity_permitted,
192
+ )
193
+ forecast = tiled[0]
194
+ simultaneity_channel = forecast[:, channel_index("coa_overlap_flag")]
195
+ violation_log: PhysicsViolationLog = validate_forecast(
196
+ forecast,
197
+ mu=mu,
198
+ wheelbase_m=wheelbase_m,
199
+ simultaneity_channel=simultaneity_channel,
200
+ bands=ToleranceBands.for_1hz_aggregation(),
201
+ )
202
+ steps.append(NodeExecutionTrace(
203
+ node="projection",
204
+ status="ok",
205
+ duration_ms=(time.time() - t0) * 1000.0,
206
+ detail=(
207
+ f"forecast_engine={forecast_engine} "
208
+ f"physics_engine={violation_log.engine} "
209
+ f"records={len(violation_log.records)} "
210
+ f"fcvr={violation_log.fcvr():.4f}"
211
+ ),
212
+ ))
213
+
214
+ # ---- Node 4: guardian -------------------------------------
215
+ t0 = time.time()
216
+ guardian_audit = Guardian().audit(
217
+ violation_log=violation_log, coa=coa,
218
+ )
219
+ steps.append(NodeExecutionTrace(
220
+ node="guardian",
221
+ status="ok",
222
+ duration_ms=(time.time() - t0) * 1000.0,
223
+ detail=(
224
+ f"verdict={guardian_audit.verdict} "
225
+ f"audit_id={guardian_audit.audit_id[:8]}"
226
+ ),
227
+ ))
228
+
229
+ # ---- Node 5: instruct -------------------------------------
230
+ # wave-48 honesty close: caller supplies the live `narrator`
231
+ # instance (OpenRouter Granite 4.1 8B via
232
+ # `apex.instruct.openrouter_generator`) when env is set;
233
+ # otherwise the deterministic schema-correct floor runs.
234
+ t0 = time.time()
235
+ narrator_inputs = NarratorInputs(
236
+ forecast=forecast,
237
+ coa=coa,
238
+ violation_log=violation_log,
239
+ guardian_audit=guardian_audit,
240
+ debrief=debrief,
241
+ )
242
+ active_narrator = narrator or Narrator()
243
+ narrator_out = active_narrator.narrate(narrator_inputs)
244
+ narrator_engine = (
245
+ "granite-4.1-8b-openrouter"
246
+ if narrator is not None else "deterministic-floor"
247
+ )
248
+ steps.append(NodeExecutionTrace(
249
+ node="instruct",
250
+ status="ok",
251
+ duration_ms=(time.time() - t0) * 1000.0,
252
+ detail=(
253
+ f"narrator_engine={narrator_engine} "
254
+ f"corners={len(narrator_out.coaching_report.corners)} "
255
+ f"retries={narrator_out.retry_count}"
256
+ ),
257
+ ))
258
+
259
+ # ---- Node 6: provenance -----------------------------------
260
+ t0 = time.time()
261
+ provenance = narrator_out.coaching_report.provenance
262
+ steps.append(NodeExecutionTrace(
263
+ node="provenance",
264
+ status="ok",
265
+ duration_ms=(time.time() - t0) * 1000.0,
266
+ detail=(
267
+ f"commit_sha={provenance.commit_sha[:8]} "
268
+ f"audit_id={guardian_audit.audit_id[:8]}"
269
+ ),
270
+ ))
271
+
272
+ return LangGraphRuntimeTrace(
273
+ steps=tuple(steps),
274
+ final_report=narrator_out.coaching_report,
275
+ )
276
+
277
+
278
+ def run_langgraph(
279
+ *,
280
+ telemetry_csv: Path | str,
281
+ coa_json: Path | str,
282
+ debrief_path: Path | str | None = None,
283
+ mu: float = 1.2,
284
+ wheelbase_m: float = 2.7,
285
+ narrator: Narrator | None = None,
286
+ ) -> LangGraphRuntimeTrace:
287
+ """Module-level convenience wrapper."""
288
+ return LangGraphRuntime().execute(
289
+ telemetry_csv=telemetry_csv,
290
+ coa_json=coa_json,
291
+ debrief_path=debrief_path,
292
+ mu=mu,
293
+ wheelbase_m=wheelbase_m,
294
+ narrator=narrator,
295
+ )
296
+
297
+
298
+ __all__ = [
299
+ "EXPECTED_NODE_ORDER",
300
+ "LangGraphRuntime",
301
+ "LangGraphRuntimeTrace",
302
+ "NodeExecutionTrace",
303
+ "NodeStatus",
304
+ "run_langgraph",
305
+ ]
apex/orchestration/session_context.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GET /api/session-context race-event tiles (Phase 4 task 4.M3c).
2
+
3
+ Spec at docs/wave-41-backend-spec-handoff.md L152-192. Mirrors the
4
+ frontend `RaceEventsTilesRow.tsx` 4-tile mock fixture.
5
+
6
+ Cache contract:
7
+ - 30s per-track cache for slow-changing fields (track-temp, weather,
8
+ tire-state)
9
+ - Session-phase tile invalidates per-lap on lap-completion event
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from typing import Final, Literal
19
+
20
+ _NOW_LOCK = threading.Lock()
21
+ _LAST_TS: list[datetime | None] = [None]
22
+
23
+ TileSeverity = Literal["ok", "monitor", "critical"]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RaceEventsTile:
28
+ key: str
29
+ label: str
30
+ value: str
31
+ detail: str
32
+ severity: TileSeverity
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SessionContextResponse:
37
+ tiles: tuple[RaceEventsTile, ...]
38
+ fetched_at_iso: str
39
+
40
+
41
+ _DEFAULT_TILES: Final[tuple[RaceEventsTile, ...]] = (
42
+ RaceEventsTile(
43
+ key="session_phase",
44
+ label="Session phase",
45
+ value="FP2 lap 14 of 22",
46
+ detail="Free Practice 2 mid-stint; long-run pace evaluation window",
47
+ severity="ok",
48
+ ),
49
+ RaceEventsTile(
50
+ key="track_temp",
51
+ label="Track temperature",
52
+ value="34 deg C",
53
+ detail="Stable at +1 deg from morning baseline; tire-thermal model in-band",
54
+ severity="ok",
55
+ ),
56
+ RaceEventsTile(
57
+ key="weather",
58
+ label="Weather",
59
+ value="Cloud cover 60%, dry",
60
+ detail="No precipitation forecast for the next 90 minutes",
61
+ severity="ok",
62
+ ),
63
+ RaceEventsTile(
64
+ key="tire_state",
65
+ label="Tire state",
66
+ value="Medium compound, lap 9",
67
+ detail="Wear within the linear-degradation regime; pit window opens lap 18",
68
+ severity="monitor",
69
+ ),
70
+ )
71
+
72
+
73
+ def build_mock_tiles() -> tuple[RaceEventsTile, ...]:
74
+ return _DEFAULT_TILES
75
+
76
+
77
+ def _utc_now_iso() -> str:
78
+ """Strictly-monotonic ISO 8601 UTC timestamp.
79
+
80
+ Microsecond precision + a monotonic-bump tiebreaker so two
81
+ back-to-back invalidations always produce distinct strings even
82
+ when the system clock resolution is coarser than the call rate.
83
+ Cache-coherence depends on this invariant per the wave-41 spec
84
+ L174-176.
85
+ """
86
+ from datetime import timedelta
87
+ with _NOW_LOCK:
88
+ now = datetime.now(timezone.utc)
89
+ prev = _LAST_TS[0]
90
+ if prev is not None and now <= prev:
91
+ now = prev + timedelta(microseconds=1)
92
+ _LAST_TS[0] = now
93
+ return now.isoformat()
94
+
95
+
96
+ class SessionContextProvider:
97
+ """30-second TTL cache with explicit lap-completion invalidation.
98
+
99
+ The 30s cache window matches the spec: track-temp + weather +
100
+ tire-state shift on slower timescales than the cache. The
101
+ session-phase tile invalidates on every lap-completion event via
102
+ `notify_lap_completion()`.
103
+ """
104
+
105
+ def __init__(self, cache_ttl_seconds: float = 30.0):
106
+ self._ttl = float(cache_ttl_seconds)
107
+ self._last_fetched_at: float | None = None
108
+ self._cache: SessionContextResponse | None = None
109
+
110
+ def notify_lap_completion(self) -> None:
111
+ """Invalidate the cache: the next .fetch() will rebuild."""
112
+ self._last_fetched_at = None
113
+ self._cache = None
114
+
115
+ def fetch(self) -> SessionContextResponse:
116
+ now = time.time()
117
+ if (
118
+ self._cache is not None
119
+ and self._last_fetched_at is not None
120
+ and now - self._last_fetched_at < self._ttl
121
+ ):
122
+ return self._cache
123
+ resp = SessionContextResponse(
124
+ tiles=build_mock_tiles(),
125
+ fetched_at_iso=_utc_now_iso(),
126
+ )
127
+ self._cache = resp
128
+ self._last_fetched_at = now
129
+ return resp
130
+
131
+
132
+ __all__ = [
133
+ "RaceEventsTile",
134
+ "SessionContextProvider",
135
+ "SessionContextResponse",
136
+ "TileSeverity",
137
+ "build_mock_tiles",
138
+ ]
apex/orchestration/what_if_replay.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """POST /api/what-if-replay deterministic re-projection (Phase 4 task 4.M3b).
2
+
3
+ Spec at docs/wave-41-backend-spec-handoff.md L91-150.
4
+
5
+ Determinism contract:
6
+ - Same (baseline_fixture_id, mutation_key) MUST produce byte-identical
7
+ replayed_violation_log per violations.py to_text() output.
8
+ - Backend MUST use the same V2 cvxpylayers projector instance + the
9
+ same friction-ellipse coefficients as /api/forecast.
10
+
11
+ The mutation catalogue is the minimum frontend the wave-41 spec
12
+ references; new mutations land here as new keys + a `.apply()` function.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Any, Callable, Final
19
+
20
+ import numpy as np
21
+
22
+ from apex.physics.projection import CvxpyLayersProjector
23
+ from apex.shared.contracts import (
24
+ CHANNEL_COUNT,
25
+ HORIZON,
26
+ PhysicsViolationLog,
27
+ PROTOCOL_VERSION,
28
+ SCHEMA_VERSION,
29
+ channel_index,
30
+ )
31
+
32
+
33
+ class UnknownFixtureError(ValueError):
34
+ """400 surface: baseline_fixture_id not in BASELINE_FIXTURES."""
35
+
36
+
37
+ class UnknownMutationError(ValueError):
38
+ """400 surface: mutation_key not in MUTATIONS."""
39
+
40
+
41
+ def _build_jerk_bound_fixture() -> np.ndarray:
42
+ """Minimal fixture producing a friction-ellipse violation under V2.
43
+
44
+ Mirrors the C14-04 jerk-bound fixture the frontend stub references.
45
+ The actual jerk-bound check is Tier-8 kinematic; for V2 projector
46
+ re-projection we surface the friction-ellipse hit as the visible
47
+ log line.
48
+ """
49
+ f = np.zeros((HORIZON, CHANNEL_COUNT), dtype=np.float32)
50
+ f[10, channel_index("long_g")] = 1.5
51
+ f[10, channel_index("speed_mps")] = 40.0
52
+ f[10, channel_index("coa_overlap_flag")] = 1.0
53
+ return f
54
+
55
+
56
+ BASELINE_FIXTURES: Final[dict[str, dict[str, Any]]] = {
57
+ "C14-04-jerk-bound": {
58
+ "id": "C14-04-jerk-bound",
59
+ "label": "Convergence-14 jerk-bound fixture (C14-04)",
60
+ "build_forecast": _build_jerk_bound_fixture,
61
+ },
62
+ }
63
+
64
+
65
+ def _mutation_coa_overlap_invert(forecast: np.ndarray) -> np.ndarray:
66
+ """Invert the COA simultaneity channel from 1.0 -> 0.0 (or vice versa).
67
+
68
+ Produces a counterfactual "what if the COA did not permit overlap"
69
+ scenario; the validator will then flag any brake+throttle overlap
70
+ that survives the re-projection.
71
+ """
72
+ mutated = forecast.copy()
73
+ idx = channel_index("coa_overlap_flag")
74
+ mutated[:, idx] = 1.0 - mutated[:, idx]
75
+ return mutated
76
+
77
+
78
+ MUTATIONS: Final[dict[str, Callable[[np.ndarray], np.ndarray]]] = {
79
+ "MUTATION_COA_OVERLAP_INVERT": _mutation_coa_overlap_invert,
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ReplayResult:
85
+ mutated_fixture: dict[str, Any]
86
+ replayed_violation_log: PhysicsViolationLog
87
+ schema_version: str
88
+ protocol_version: str
89
+
90
+
91
+ # Module-level projector. Single instance per process so the
92
+ # cvxpylayers DPP-compiled problem is reused across calls (matches the
93
+ # /api/forecast determinism contract per spec L130-138).
94
+ _projector_singleton: CvxpyLayersProjector | None = None
95
+
96
+
97
+ def _get_projector() -> CvxpyLayersProjector:
98
+ global _projector_singleton
99
+ if _projector_singleton is None:
100
+ _projector_singleton = CvxpyLayersProjector()
101
+ return _projector_singleton
102
+
103
+
104
+ def run_what_if_replay(
105
+ *,
106
+ baseline_fixture_id: str,
107
+ mutation_key: str,
108
+ ) -> ReplayResult:
109
+ """Run the V2 projector over the mutated fixture; return the
110
+ re-projected violation log.
111
+
112
+ Determinism: caller may call this function any number of times
113
+ with the same arguments and receive byte-identical
114
+ `replayed_violation_log.to_text()` output. The cvxpylayers solve
115
+ is itself deterministic given the same DPP-compiled problem +
116
+ same input tensor; the singleton + fixed-fixture path guarantees
117
+ those invariants.
118
+ """
119
+ if baseline_fixture_id not in BASELINE_FIXTURES:
120
+ raise UnknownFixtureError(
121
+ f"baseline_fixture_id {baseline_fixture_id!r} not in "
122
+ f"BASELINE_FIXTURES; known keys: {sorted(BASELINE_FIXTURES.keys())}"
123
+ )
124
+ if mutation_key not in MUTATIONS:
125
+ raise UnknownMutationError(
126
+ f"mutation_key {mutation_key!r} not in MUTATIONS; known keys: "
127
+ f"{sorted(MUTATIONS.keys())}"
128
+ )
129
+
130
+ import torch
131
+
132
+ fixture = BASELINE_FIXTURES[baseline_fixture_id]
133
+ baseline = fixture["build_forecast"]()
134
+ mutated = MUTATIONS[mutation_key](baseline)
135
+ tensor = torch.from_numpy(mutated).unsqueeze(0).float()
136
+ result = _get_projector().project(tensor)
137
+ return ReplayResult(
138
+ mutated_fixture={
139
+ "id": baseline_fixture_id,
140
+ "mutation": mutation_key,
141
+ "shape": list(mutated.shape),
142
+ },
143
+ replayed_violation_log=result.violation_log,
144
+ schema_version=SCHEMA_VERSION,
145
+ protocol_version=PROTOCOL_VERSION,
146
+ )
147
+
148
+
149
+ __all__ = [
150
+ "BASELINE_FIXTURES",
151
+ "MUTATIONS",
152
+ "ReplayResult",
153
+ "UnknownFixtureError",
154
+ "UnknownMutationError",
155
+ "run_what_if_replay",
156
+ ]
apex/physics/.gitkeep ADDED
File without changes
apex/physics/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """APEX physics layer: validator + projection + SCP solve."""
apex/physics/projection.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V2 cvxpylayers differentiable physics projector (Phase 2 Day 5 task 2.12).
2
+
3
+ Production class form of the D-027 Stage C SCP spike (Phase 0 task 0.5;
4
+ logs/day-03-scp-go-no-go.md). Same constant-mu friction ellipse, same
5
+ per-step decoupled formulation, same DPP-compliant cvxpy problem;
6
+ now wrapped behind the DifferentiableProjector Protocol so V1 NumPy
7
+ floor and V2 cvxpylayers ceiling swap at any caller (V1 is a future
8
+ implementation; V2 ships now).
9
+
10
+ Engine-agnostic boundary (Long-Term Architect load-bearing wall #2):
11
+ this projector emits the same `PhysicsViolationLog` schema the V1
12
+ validator emits, with `engine="v2_cvxpylayers"`. The violation_log
13
+ .to_text() output is byte-identical to V1's friction_ellipse_check
14
+ .to_text() on the same step-and-channel content; the engine string is
15
+ the only intentional difference. Cross-engine type + step parity is
16
+ covered in tests/test_physics_v2.py.
17
+
18
+ Staged scope per D-031:
19
+ - Constant-mu friction ellipse single iterate: ships now (this file).
20
+ - 8-tier Pacejka linearization: deferred to projection_pacejka.py.
21
+ - 3-iteration SCP unroll: deferred to projection_scp.py.
22
+
23
+ If the staged ladder rungs are not reached by Day 5 EOD, plan task
24
+ 2.13 explicitly allows V1 NumPy floor as the ship-version; D-A still
25
+ holds because the violation strings are engine-agnostic and the
26
+ NeurIPS paper §3.2 canonical-engine framing remains honest.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Final
32
+
33
+ import torch
34
+
35
+ from apex.shared.contracts import (
36
+ CHANNEL_COUNT,
37
+ HORIZON,
38
+ PhysicsViolationLog,
39
+ ProjectionResult,
40
+ ViolationRecord,
41
+ channel_index,
42
+ )
43
+
44
+ DEFAULT_MU: Final[float] = 1.2
45
+ """Nominal grip coefficient for the demo. Tier-5 thermal + Tier-7 Pacejka
46
+ expansion (D-015) overrides this per step in projection_pacejka.py."""
47
+
48
+ DEFAULT_TOLERANCE: Final[float] = 1e-3
49
+ """Solver-output tolerance: a corrected step whose norm exceeds mu by less
50
+ than this still counts as inside the feasible set. Matches the spike's
51
+ fcvr() tolerance at scp_spike.py L117."""
52
+
53
+
54
+ class CvxpyLayersProjector:
55
+ """Differentiable projection onto the constant-mu friction ellipse.
56
+
57
+ The projection solves, per horizon step:
58
+ min || a_out - a_in ||_2^2
59
+ s.t. || a_out ||_2 <= mu
60
+
61
+ where `a_in = (long_g, lat_g)` from the upstream forecast and `a_out`
62
+ is the projected feasible pair. `a_in` is a cp.Parameter; `a_out` is
63
+ a cp.Variable; cvxpylayers wraps the resulting cp.Problem in a
64
+ torch.nn.Module so .backward() flows through the QP solve.
65
+
66
+ The cvxpy problem is built once at __init__ and reused across calls;
67
+ only the parameter values change per forecast (DPP discipline).
68
+ """
69
+
70
+ is_differentiable: bool = True
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ mu: float = DEFAULT_MU,
76
+ tolerance: float = DEFAULT_TOLERANCE,
77
+ ):
78
+ import cvxpy as cp
79
+ from cvxpylayers.torch import CvxpyLayer
80
+
81
+ self.mu = float(mu)
82
+ self.tolerance = float(tolerance)
83
+
84
+ a_in = cp.Parameter(2)
85
+ a_out = cp.Variable(2)
86
+ constraints = [cp.norm(a_out, 2) <= self.mu]
87
+ objective = cp.Minimize(cp.sum_squares(a_out - a_in))
88
+ prob = cp.Problem(objective, constraints)
89
+ assert prob.is_dpp(), (
90
+ "friction-ellipse projection must be DPP for cvxpylayers"
91
+ )
92
+ self._layer = CvxpyLayer(prob, parameters=[a_in], variables=[a_out])
93
+
94
+ def project(self, forecast: torch.Tensor) -> ProjectionResult:
95
+ """Project `forecast` of shape (B, HORIZON, CHANNEL_COUNT) onto the
96
+ per-step friction ellipse.
97
+
98
+ Returns ProjectionResult(corrected_tensor, violation_log) where
99
+ corrected_tensor preserves the input shape + dtype + device, and
100
+ violation_log carries one ViolationRecord per step whose
101
+ pre-projection (long_g, lat_g) norm exceeded `mu` + tolerance.
102
+ """
103
+ if forecast.ndim != 3 or forecast.shape[1] != HORIZON or forecast.shape[2] != CHANNEL_COUNT:
104
+ raise ValueError(
105
+ f"CvxpyLayersProjector.project expects shape (B, {HORIZON}, "
106
+ f"{CHANNEL_COUNT}); got {tuple(forecast.shape)}"
107
+ )
108
+
109
+ long_idx = channel_index("long_g")
110
+ lat_idx = channel_index("lat_g")
111
+
112
+ # Per-step pair extraction: (B, H, 2)
113
+ pairs = torch.stack(
114
+ [forecast[:, :, long_idx], forecast[:, :, lat_idx]], dim=-1
115
+ )
116
+
117
+ # cvxpylayers expects (N, 2); flatten over (B, H) then unflatten.
118
+ B, H, _ = pairs.shape
119
+ pairs_flat = pairs.reshape(B * H, 2)
120
+ (projected_flat,) = self._layer(pairs_flat)
121
+ projected = projected_flat.reshape(B, H, 2)
122
+
123
+ # Recompose the corrected tensor channel-by-channel so the long_g
124
+ # and lat_g channels carry the projection's grad-fn while every
125
+ # other channel passes through untouched. In-place overwrite of
126
+ # a clone would silently detach those slots from autograd; the
127
+ # unbind + stack route keeps the graph intact.
128
+ channels = list(torch.unbind(forecast, dim=-1))
129
+ channels[long_idx] = projected[:, :, 0]
130
+ channels[lat_idx] = projected[:, :, 1]
131
+ corrected = torch.stack(channels, dim=-1)
132
+
133
+ # Build violation log from pre-projection norms.
134
+ pre_norms = torch.linalg.vector_norm(pairs, dim=-1) # (B, H)
135
+ records: list[ViolationRecord] = []
136
+ # We log only batch index 0's violations into a single log
137
+ # because PhysicsViolationLog is per-forecast, not per-batch.
138
+ # Multi-batch projection is supported numerically but the log
139
+ # surface assumes B=1 (the V1 validator + Day-5 narrator path).
140
+ for step in range(H):
141
+ norm = pre_norms[0, step].item()
142
+ if norm > self.mu + self.tolerance:
143
+ records.append(
144
+ ViolationRecord(
145
+ step=int(step),
146
+ type="friction_ellipse_exceeded",
147
+ severity=float(norm - self.mu),
148
+ channel_values={
149
+ "long_g": float(pairs[0, step, 0].item()),
150
+ "lat_g": float(pairs[0, step, 1].item()),
151
+ },
152
+ tier=7,
153
+ )
154
+ )
155
+
156
+ log = PhysicsViolationLog(
157
+ records=records,
158
+ forecast_step_count=int(H),
159
+ engine="v2_cvxpylayers",
160
+ )
161
+ return ProjectionResult(corrected_tensor=corrected, violation_log=log)
162
+
163
+
164
+ __all__ = ["CvxpyLayersProjector", "DEFAULT_MU", "DEFAULT_TOLERANCE"]
apex/physics/scp_spike.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """D-027 Stage C SCP spike (Phase 0 task 0.5, council v2 staged).
2
+
3
+ What this proves: gradient flow through TTM-r2 forecast -> cvxpylayers
4
+ friction-ellipse projection -> scalar loss -> .backward(). Single SCP
5
+ iterate, constant-mu (NOT 8-tier Pacejka), no trust-region. 8-tier
6
+ linearization and 3-iteration unroll move to Day 4 task 2.12 if Stage C
7
+ passes; cut to V1 NumPy floor if Stage C fails.
8
+
9
+ Pass criteria (council v2 numeric definition):
10
+ - Forward pass completes without NaN/Inf
11
+ - ||grad_L|| < 1e4 (finite + below "oscillating" threshold)
12
+ - FCVR = 0.00 on the Sarah stub (every projected step inside the
13
+ feasible set: long_g**2 + lat_g**2 <= (mu * g)**2)
14
+
15
+ Fail criteria ("oscillates"):
16
+ - NaN/Inf anywhere
17
+ - ||grad_L|| >= 1e4
18
+ - residual non-decrease over 2 consecutive iterates (single-iterate
19
+ here, so this clause activates only at Day 4 when we add unroll)
20
+
21
+ Run:
22
+ cd <repo root>
23
+ app/backend/.venv/Scripts/python.exe -u app/backend/apex/physics/scp_spike.py
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import csv
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+
33
+ import torch
34
+
35
+ # Make apex.* importable when running this file directly from the repo root.
36
+ REPO_ROOT = Path(__file__).resolve().parents[4]
37
+ sys.path.insert(0, str(REPO_ROOT / "app" / "backend"))
38
+
39
+ from apex.shared.contracts import CHANNELS, CHANNEL_COUNT, HORIZON, channel_index # noqa: E402
40
+
41
+ # Physics constants for the constant-mu friction ellipse.
42
+ MU_NOMINAL = 1.2 # nominal grip coefficient (single-tier; D-015 Tier 7 swaps later)
43
+ G = 9.81 # m/s^2
44
+ GRIP_LIMIT_G = MU_NOMINAL # in g-units the ellipse is the circle a_long^2 + a_lat^2 <= mu^2
45
+
46
+ # Pass-criterion thresholds (council v2 Software Lead).
47
+ GRAD_NORM_OSCILLATES_AT = 1e4
48
+ FCVR_TARGET = 0.0
49
+
50
+
51
+ def load_sarah_stub() -> torch.Tensor:
52
+ """Load the 10-row Sarah Reynolds stub as a (1, 10, 14) float tensor.
53
+
54
+ Returns the tensor in CHANNELS column order. Tile/repeat happens at
55
+ the TTM input adapter, not here.
56
+ """
57
+ csv_path = REPO_ROOT / "fixtures" / "personas" / "sarah-reynolds-telemetry-stub.csv"
58
+ with csv_path.open() as f:
59
+ lines = [line for line in f if not line.startswith("#")]
60
+ reader = csv.DictReader(lines)
61
+ header = reader.fieldnames
62
+ assert tuple(header) == CHANNELS, (
63
+ f"Sarah stub column order {header} != shapes.py CHANNELS contract"
64
+ )
65
+ rows = [[float(r[c]) for c in CHANNELS] for r in reader]
66
+ t = torch.tensor(rows, dtype=torch.float32).unsqueeze(0) # (1, 10, 14)
67
+ return t
68
+
69
+
70
+ def pad_to_context(x: torch.Tensor, context_length: int) -> torch.Tensor:
71
+ """Pad a (B, T<context, 14) tensor to (B, context, 14) by edge-replicating
72
+ the first row (TTM-r2 expects 512 timesteps of history).
73
+
74
+ Edge replication is a defensible pad for a hackathon spike: it preserves
75
+ the channel statistics and avoids zero-imputation discontinuities. Real
76
+ fixtures (Phase 3 task 3.2) provide >512 actual timesteps; this stub is
77
+ just for the gradient-flow proof.
78
+ """
79
+ B, T, C = x.shape
80
+ if T >= context_length:
81
+ return x[:, -context_length:, :]
82
+ front = x[:, :1, :].expand(B, context_length - T, C)
83
+ return torch.cat([front, x], dim=1)
84
+
85
+
86
+ def build_friction_ellipse_projector():
87
+ """Build the cvxpylayers projection: project per-step (a_long, a_lat) onto
88
+ the constant-mu ellipse (here a circle of radius mu in g-units).
89
+
90
+ Returns a callable layer(a_in: (N, 2)) -> (N, 2) projected_g_pair.
91
+
92
+ Why this shape: the SCP solver decouples horizon-step independence by
93
+ projecting each timestep's (long_g, lat_g) pair separately. Stage C uses
94
+ the single-step formulation; Day 4 task 2.12 will batch this into a
95
+ joint QP across all 30 horizon steps with cross-step kinematic coupling.
96
+ """
97
+ import cvxpy as cp
98
+ from cvxpylayers.torch import CvxpyLayer
99
+
100
+ a_in = cp.Parameter(2) # observed (long_g, lat_g) from TTM forecast
101
+ a_out = cp.Variable(2) # projected feasible pair
102
+ constraints = [cp.norm(a_out, 2) <= GRIP_LIMIT_G]
103
+ objective = cp.Minimize(cp.sum_squares(a_out - a_in))
104
+ prob = cp.Problem(objective, constraints)
105
+ assert prob.is_dpp(), "Friction-ellipse projection must be DPP for cvxpylayers"
106
+ layer = CvxpyLayer(prob, parameters=[a_in], variables=[a_out])
107
+ return layer
108
+
109
+
110
+ def fcvr(g_pairs: torch.Tensor, tol: float = 1e-4) -> float:
111
+ """Forecast Constraint Violation Rate: fraction of (long_g, lat_g) steps
112
+ where sqrt(long_g^2 + lat_g^2) exceeds the friction-ellipse boundary.
113
+
114
+ A projection layer that is doing its job emits an output with FCVR ~= 0.
115
+ """
116
+ norms = torch.linalg.vector_norm(g_pairs, dim=-1)
117
+ violations = (norms > GRIP_LIMIT_G + tol).float()
118
+ return violations.mean().item()
119
+
120
+
121
+ def main() -> int:
122
+ print("=" * 72)
123
+ print("D-027 Stage C SCP spike (Phase 0 task 0.5, council v2 staged)")
124
+ print("=" * 72)
125
+ print(f"device: {'cuda:0 ' + torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'}")
126
+ print(f"mu_nominal: {MU_NOMINAL}, grip_limit_g: {GRIP_LIMIT_G}")
127
+ print(f"target shape: (B, {HORIZON}, {CHANNEL_COUNT})")
128
+ print(f"gradient oscillates-at threshold: {GRAD_NORM_OSCILLATES_AT}")
129
+ print(f"FCVR target: {FCVR_TARGET}")
130
+ print()
131
+
132
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
133
+
134
+ # ---- Load TTM-r2 -----------------------------------------------------
135
+ print("[1/5] loading TTM-r2 ...")
136
+ t0 = time.time()
137
+ from tsfm_public import TinyTimeMixerForPrediction
138
+ model = TinyTimeMixerForPrediction.from_pretrained(
139
+ "ibm-granite/granite-timeseries-ttm-r2",
140
+ num_input_channels=CHANNEL_COUNT,
141
+ prediction_filter_length=HORIZON,
142
+ ).to(device).eval()
143
+ print(f" loaded in {time.time()-t0:.2f}s; context_length={model.config.context_length}")
144
+
145
+ # ---- Build TTM input from Sarah stub --------------------------------
146
+ print("[2/5] building TTM input from Sarah stub ...")
147
+ sarah = load_sarah_stub().to(device)
148
+ print(f" stub shape: {tuple(sarah.shape)}")
149
+ ttm_input = pad_to_context(sarah, model.config.context_length)
150
+ # Make input require grad so we can call .backward through TTM as well.
151
+ ttm_input = ttm_input.detach().clone().requires_grad_(True)
152
+ print(f" ttm_input shape: {tuple(ttm_input.shape)} requires_grad={ttm_input.requires_grad}")
153
+
154
+ # ---- TTM forward ----------------------------------------------------
155
+ print("[3/5] TTM forward pass ...")
156
+ t0 = time.time()
157
+ out = model(past_values=ttm_input)
158
+ forecast = out.prediction_outputs # (B, 30, 14)
159
+ print(f" forecast shape: {tuple(forecast.shape)} in {(time.time()-t0)*1000:.1f}ms")
160
+ assert tuple(forecast.shape) == (1, HORIZON, CHANNEL_COUNT), (
161
+ f"TTM output shape {tuple(forecast.shape)} violates shapes.py contract"
162
+ )
163
+
164
+ long_idx = channel_index("long_g")
165
+ lat_idx = channel_index("lat_g")
166
+ g_pairs_raw = torch.stack(
167
+ [forecast[:, :, long_idx], forecast[:, :, lat_idx]], dim=-1
168
+ ) # (1, 30, 2)
169
+
170
+ pre_norms = torch.linalg.vector_norm(g_pairs_raw, dim=-1)
171
+ pre_fcvr = fcvr(g_pairs_raw)
172
+ print(f" pre-projection ||(long_g,lat_g)|| max={pre_norms.max().item():.3f} min={pre_norms.min().item():.3f}")
173
+ print(f" pre-projection FCVR: {pre_fcvr:.4f}")
174
+
175
+ # ---- cvxpylayers projection (single SCP iterate, constant-mu) -------
176
+ print("[4/5] cvxpylayers friction-ellipse projection ...")
177
+ layer = build_friction_ellipse_projector()
178
+ # cvxpylayers expects (N, 2) for a vector parameter; flatten over (B, H).
179
+ pairs_flat = g_pairs_raw.reshape(-1, 2) # (30, 2)
180
+ t0 = time.time()
181
+ (projected_flat,) = layer(pairs_flat)
182
+ projected = projected_flat.reshape(1, HORIZON, 2)
183
+ print(f" projection took {(time.time()-t0)*1000:.1f}ms")
184
+ post_fcvr = fcvr(projected)
185
+ post_norms = torch.linalg.vector_norm(projected, dim=-1)
186
+ print(f" post-projection ||(long_g,lat_g)|| max={post_norms.max().item():.3f} min={post_norms.min().item():.3f}")
187
+ print(f" post-projection FCVR: {post_fcvr:.4f}")
188
+
189
+ # ---- Backward + gradient norm ---------------------------------------
190
+ print("[5/5] backward pass + gradient norm ...")
191
+ # Use sum-of-squares of the projected pair as the scalar loss; this is a
192
+ # smooth function with non-trivial gradient through both the projection
193
+ # and the TTM forward. If gradient flows here, it flows through both.
194
+ loss = (projected ** 2).sum()
195
+ t0 = time.time()
196
+ loss.backward()
197
+ print(f" backward took {(time.time()-t0)*1000:.1f}ms")
198
+ grad = ttm_input.grad
199
+ assert grad is not None, "Gradient did not propagate to ttm_input"
200
+ grad_norm = torch.linalg.vector_norm(grad).item()
201
+ grad_finite = bool(torch.isfinite(grad).all())
202
+ grad_max_abs = grad.abs().max().item()
203
+ print(f" loss = {loss.item():.6f}")
204
+ print(f" ||grad_L|| = {grad_norm:.4f}")
205
+ print(f" grad finite: {grad_finite}")
206
+ print(f" max |grad_L_i| = {grad_max_abs:.6f}")
207
+
208
+ # ---- Verdict --------------------------------------------------------
209
+ print()
210
+ print("=" * 72)
211
+ pass_grad = grad_finite and grad_norm < GRAD_NORM_OSCILLATES_AT
212
+ pass_fcvr = post_fcvr <= FCVR_TARGET + 1e-6
213
+ verdict = pass_grad and pass_fcvr
214
+ print(f"VERDICT: {'PASS' if verdict else 'FAIL'}")
215
+ print(f" grad finite + ||grad|| < {GRAD_NORM_OSCILLATES_AT}: {pass_grad}")
216
+ print(f" FCVR <= {FCVR_TARGET}: {pass_fcvr} (got {post_fcvr:.6f})")
217
+ print("=" * 72)
218
+ return 0 if verdict else 1
219
+
220
+
221
+ if __name__ == "__main__":
222
+ sys.exit(main())
apex/physics/validator.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V1 NumPy physics validator (Phase 2 Day 4 implementation; Phase 0 task 0.11 sketch).
2
+
3
+ Signatures only at Phase 0. Implementations land at Phase 2 Day 4 tasks 2.1-2.5
4
+ behind G3 (validator catches 5 impossibilities + approves 5 valid). The
5
+ engine-agnostic boundary lives in shared.contracts.PhysicsViolationLog;
6
+ this module's job is to emit that exact type so V1 NumPy output is
7
+ byte-identical to V2 cvxpylayers output on the same telemetry input.
8
+
9
+ Council v2 Software Lead fix #7: forward-Euler tolerance is channel-specific
10
+ (m/s for speed integration, m/s^2 for acceleration integration); a single
11
+ scalar band either misses real violations or accepts everything. The
12
+ ToleranceBands dataclass below carries the per-channel bounds.
13
+
14
+ Implementations of these functions land in Phase 2 Day 4. Phase 0 stops
15
+ at signatures + docstrings + the tolerance-band contract so the next file
16
+ to land knows what it imports.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ import numpy as np
24
+
25
+ from apex.shared.contracts import (
26
+ CHANNEL_TIER_BINDING,
27
+ PhysicsViolationLog,
28
+ ViolationRecord,
29
+ channel_index,
30
+ )
31
+
32
+
33
+ # ---- Tolerance bands (council v2 Software Lead fix #7) -----------------
34
+
35
+ @dataclass(frozen=True)
36
+ class ToleranceBands:
37
+ """Channel-specific tolerance bands for the forward-Euler consistency check.
38
+
39
+ A scalar tolerance was the original mis-design: at 1 Hz aggregation and
40
+ peak long_g ~ 1.0g (~9.8 m/s^2), Delta-v quantization is up to ~9.8 m/s
41
+ per step. The validator must accept that quantization as legitimate
42
+ while still catching actual physically-impossible Delta-v transitions.
43
+
44
+ Defaults below are derived for the 1 Hz aggregation rate (D-011 path A).
45
+ The polyphase 50 Hz path (D-011 path B) reduces these bounds by ~50x;
46
+ the FlowState rate-invariant path (D-011 path C) uses a different metric
47
+ entirely (per-token surprisal). Each path passes its own ToleranceBands
48
+ instance.
49
+ """
50
+
51
+ delta_v_band_mps: float = 9.8 # 1g * 1s; 1 Hz quantization ceiling
52
+ delta_long_g_band: float = 1.0 # 1g change per step at 1 Hz
53
+ delta_lat_g_band: float = 1.2 # mu_nominal grip ceiling
54
+ delta_steering_rad_band: float = 0.5 # max steering rate at 1 Hz
55
+ delta_yaw_rate_rad_s_band: float = 1.5 # max yaw-rate change at 1 Hz
56
+
57
+ @classmethod
58
+ def for_1hz_aggregation(cls) -> "ToleranceBands":
59
+ """Default tolerance bands for the 1 Hz mini-sector aggregation path
60
+ (D-011 path A; the macroscopic backbone).
61
+ """
62
+ return cls()
63
+
64
+ @classmethod
65
+ def for_polyphase_50hz(cls) -> "ToleranceBands":
66
+ """Tolerance bands for the polyphase 50 Hz path (D-011 path B).
67
+
68
+ At 50 Hz the per-step Delta-v ceiling shrinks from 1g*1s = 9.8 m/s
69
+ to 1g*0.02s = 0.196 m/s. Same physics, finer time resolution.
70
+ """
71
+ return cls(
72
+ delta_v_band_mps=0.196,
73
+ delta_long_g_band=0.02,
74
+ delta_lat_g_band=0.024,
75
+ delta_steering_rad_band=0.01,
76
+ delta_yaw_rate_rad_s_band=0.03,
77
+ )
78
+
79
+
80
+ # ---- Validator function signatures (Phase 2 Day 4 lands implementations) ----
81
+
82
+ def friction_ellipse_check(
83
+ long_g: np.ndarray,
84
+ lat_g: np.ndarray,
85
+ mu: float,
86
+ g: float = 9.81,
87
+ ) -> PhysicsViolationLog:
88
+ """Constant-mu V1 friction-ellipse check (Phase 2 Day 4 task 2.1).
89
+
90
+ long_g, lat_g: shape (horizon,) per-step acceleration in g-units.
91
+ mu: nominal friction coefficient (1.2 default for the demo).
92
+ g: gravity constant.
93
+
94
+ Returns a PhysicsViolationLog with one ViolationRecord per step where
95
+ sqrt(long_g^2 + lat_g^2) > mu. The same step can appear in multiple
96
+ logs across validator functions; the projection layer concatenates.
97
+
98
+ Engine-agnostic invariant: this function emits ViolationRecord(
99
+ type='friction_ellipse_exceeded', tier=7, ...) and the V2 cvxpylayers
100
+ projector emits the same type for the same step on the same input.
101
+ """
102
+ long_arr = np.asarray(long_g, dtype=np.float64)
103
+ lat_arr = np.asarray(lat_g, dtype=np.float64)
104
+ magnitude = np.sqrt(long_arr * long_arr + lat_arr * lat_arr)
105
+ records: list[ViolationRecord] = []
106
+ for step in np.flatnonzero(magnitude > mu):
107
+ records.append(
108
+ ViolationRecord(
109
+ step=int(step),
110
+ type="friction_ellipse_exceeded",
111
+ severity=float(magnitude[step] - mu),
112
+ channel_values={
113
+ "long_g": float(long_arr[step]),
114
+ "lat_g": float(lat_arr[step]),
115
+ },
116
+ tier=7,
117
+ )
118
+ )
119
+ return PhysicsViolationLog(
120
+ records=records,
121
+ forecast_step_count=int(long_arr.shape[0]),
122
+ engine="v1_numpy",
123
+ )
124
+
125
+
126
+ def forward_euler_consistency(
127
+ speed_mps: np.ndarray,
128
+ long_g: np.ndarray,
129
+ dt: float,
130
+ bands: ToleranceBands,
131
+ ) -> PhysicsViolationLog:
132
+ """Kinematic consistency: v[t+1] - v[t] approx long_g[t] * g * dt.
133
+
134
+ bands.delta_v_band_mps is the tolerance for the Delta-v residual; bands
135
+ parameterization is the Software Lead fix #7 (council v2). Channel-specific
136
+ tolerances live in ToleranceBands; this function reads bands.delta_v_band_mps
137
+ and bands.delta_long_g_band only.
138
+
139
+ Emits ViolationRecord(type='forward_euler_inconsistent', tier=8, ...)
140
+ for each step where the residual exceeds the band.
141
+ """
142
+ speed_arr = np.asarray(speed_mps, dtype=np.float64)
143
+ long_arr = np.asarray(long_g, dtype=np.float64)
144
+ horizon = int(speed_arr.shape[0])
145
+
146
+ expected_delta_v = long_arr[:-1] * 9.81 * dt
147
+ actual_delta_v = speed_arr[1:] - speed_arr[:-1]
148
+ residual = np.abs(actual_delta_v - expected_delta_v)
149
+
150
+ records: list[ViolationRecord] = []
151
+ for idx in np.flatnonzero(residual > bands.delta_v_band_mps):
152
+ step = int(idx)
153
+ records.append(
154
+ ViolationRecord(
155
+ step=step,
156
+ type="forward_euler_inconsistent",
157
+ severity=float(residual[step] - bands.delta_v_band_mps),
158
+ channel_values={
159
+ "speed_mps": float(speed_arr[step]),
160
+ "speed_mps_next": float(speed_arr[step + 1]),
161
+ "long_g": float(long_arr[step]),
162
+ },
163
+ tier=8,
164
+ )
165
+ )
166
+ return PhysicsViolationLog(
167
+ records=records, forecast_step_count=horizon, engine="v1_numpy"
168
+ )
169
+
170
+
171
+ def bicycle_kinematic_check(
172
+ lat_g: np.ndarray,
173
+ steering_rad: np.ndarray,
174
+ speed_mps: np.ndarray,
175
+ wheelbase_m: float,
176
+ bands: ToleranceBands,
177
+ ) -> PhysicsViolationLog:
178
+ """Bicycle model kinematic check: lat_g approx steering_rad * speed^2 / (wheelbase * g).
179
+
180
+ Detects steering/speed/lateral-accel triplets that violate the small-angle
181
+ bicycle approximation. Emits type='bicycle_kinematic_break', tier=8.
182
+ """
183
+ lat_arr = np.asarray(lat_g, dtype=np.float64)
184
+ steer_arr = np.asarray(steering_rad, dtype=np.float64)
185
+ speed_arr = np.asarray(speed_mps, dtype=np.float64)
186
+ horizon = int(lat_arr.shape[0])
187
+
188
+ expected_lat_g = steer_arr * speed_arr * speed_arr / (wheelbase_m * 9.81)
189
+ residual = np.abs(lat_arr - expected_lat_g)
190
+
191
+ records: list[ViolationRecord] = []
192
+ for idx in np.flatnonzero(residual > bands.delta_lat_g_band):
193
+ step = int(idx)
194
+ records.append(
195
+ ViolationRecord(
196
+ step=step,
197
+ type="bicycle_kinematic_break",
198
+ severity=float(residual[step] - bands.delta_lat_g_band),
199
+ channel_values={
200
+ "lat_g": float(lat_arr[step]),
201
+ "steering_rad": float(steer_arr[step]),
202
+ "speed_mps": float(speed_arr[step]),
203
+ },
204
+ tier=8,
205
+ )
206
+ )
207
+ return PhysicsViolationLog(
208
+ records=records, forecast_step_count=horizon, engine="v1_numpy"
209
+ )
210
+
211
+
212
+ def coa_simultaneity_rule(
213
+ throttle_pct: np.ndarray,
214
+ brake_pa: np.ndarray,
215
+ simultaneity_channel: np.ndarray,
216
+ ) -> PhysicsViolationLog:
217
+ """COA-derived brake-throttle overlap check.
218
+
219
+ simultaneity_channel is the per-step (horizon,) tensor sourced from
220
+ shared.contracts.build_ttm_input() (the SINGLE place that tiles the
221
+ scalar COA flag to per-step values per Software Lead fix #2). NOT a
222
+ scalar bool here; the validator receives the already-tiled tensor.
223
+
224
+ Emits type='coa_simultaneity_violation', tier=0, for each step where
225
+ throttle and brake overlap AND simultaneity_channel[step] == 0
226
+ (COA does not permit overlap for this driver/vehicle).
227
+ """
228
+ thr_arr = np.asarray(throttle_pct, dtype=np.float64)
229
+ brk_arr = np.asarray(brake_pa, dtype=np.float64)
230
+ sim_arr = np.asarray(simultaneity_channel, dtype=np.float64)
231
+ horizon = int(thr_arr.shape[0])
232
+
233
+ overlap = (thr_arr > 0.0) & (brk_arr > 0.0)
234
+ forbidden = sim_arr <= 0.5
235
+ flagged = overlap & forbidden
236
+
237
+ records: list[ViolationRecord] = []
238
+ for idx in np.flatnonzero(flagged):
239
+ step = int(idx)
240
+ records.append(
241
+ ViolationRecord(
242
+ step=step,
243
+ type="coa_simultaneity_violation",
244
+ severity=0.0,
245
+ channel_values={
246
+ "throttle_pct": float(thr_arr[step]),
247
+ "brake_pa": float(brk_arr[step]),
248
+ "coa_overlap_flag": float(sim_arr[step]),
249
+ },
250
+ tier=0,
251
+ )
252
+ )
253
+ return PhysicsViolationLog(
254
+ records=records, forecast_step_count=horizon, engine="v1_numpy"
255
+ )
256
+
257
+
258
+ def validate_forecast(
259
+ forecast: np.ndarray,
260
+ mu: float,
261
+ wheelbase_m: float,
262
+ simultaneity_channel: np.ndarray,
263
+ bands: ToleranceBands | None = None,
264
+ ) -> PhysicsViolationLog:
265
+ """Top-level V1 validator: runs all checks + merges results.
266
+
267
+ forecast: shape (horizon, channels) per shapes.TENSOR_SHAPE (drop batch axis).
268
+ Returns merged PhysicsViolationLog with engine='v1_numpy'.
269
+
270
+ Phase 2 Day 4 task 2.5 implementation. Phase 0 ships only the signature
271
+ so downstream modules can type-hint against it.
272
+ """
273
+ f = np.asarray(forecast, dtype=np.float64)
274
+ if f.ndim != 2 or f.shape[1] != len(CHANNEL_TIER_BINDING):
275
+ raise ValueError(
276
+ f"validate_forecast expects (horizon, channels) per shapes.TENSOR_SHAPE; "
277
+ f"got {f.shape}"
278
+ )
279
+
280
+ if bands is None:
281
+ bands = ToleranceBands.for_1hz_aggregation()
282
+
283
+ long_g = f[:, channel_index("long_g")]
284
+ lat_g = f[:, channel_index("lat_g")]
285
+ speed_mps = f[:, channel_index("speed_mps")]
286
+ steering_rad = f[:, channel_index("steering_rad")]
287
+ throttle_pct = f[:, channel_index("throttle_pct")]
288
+ brake_pa = f[:, channel_index("brake_pa")]
289
+
290
+ merged: list[ViolationRecord] = []
291
+ merged.extend(friction_ellipse_check(long_g, lat_g, mu).records)
292
+ merged.extend(forward_euler_consistency(speed_mps, long_g, dt=1.0, bands=bands).records)
293
+ merged.extend(bicycle_kinematic_check(
294
+ lat_g, steering_rad, speed_mps, wheelbase_m, bands
295
+ ).records)
296
+ merged.extend(coa_simultaneity_rule(throttle_pct, brake_pa, simultaneity_channel).records)
297
+
298
+ return PhysicsViolationLog(
299
+ records=merged,
300
+ forecast_step_count=int(f.shape[0]),
301
+ engine="v1_numpy",
302
+ )
303
+
304
+
305
+ __all__ = [
306
+ "ToleranceBands",
307
+ "bicycle_kinematic_check",
308
+ "coa_simultaneity_rule",
309
+ "forward_euler_consistency",
310
+ "friction_ellipse_check",
311
+ "validate_forecast",
312
+ ]
apex/pipelines/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """End-to-end pipeline entry points.
2
+
3
+ Each module here is a runnable script that wires the per-layer modules
4
+ (intake -> ttm -> physics -> guardian -> instruct) into a single demo
5
+ flow. Unit tests cover the per-layer modules; integration tests in
6
+ app/backend/tests/test_*_integration.py cover the wiring.
7
+ """
apex/pipelines/g4_mae_bakeoff.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """G4 MAE bake-off: zero-shot TTM-r2 vs seasonal-naive on FastF1 holdouts.
2
+
3
+ Phase 2 Day 4 task 2.11 per docs/vinh-backend-plan.md L59 + L152.
4
+
5
+ Pass criterion (plan L59):
6
+ holdout = laps 4-5 of fixture session (Hamilton Bahrain 2024 Q),
7
+ seed = 42,
8
+ channels = speed_mps + long_g (long_g absent from FastF1 per pre-mortem
9
+ row 62; we report speed_mps as the load-bearing comparison and
10
+ document the long_g gap),
11
+ metric = per-channel MAE delta (TTM beats seasonal-naive by any margin
12
+ in our favor counts as PASS).
13
+
14
+ Failure mode: TTM MAE >= naive MAE -> fine-tune-first pivot per plan L377
15
+ "decision triggers" table, skip the zero-shot pitch claim, escalate to
16
+ Stephen.
17
+
18
+ Methodology:
19
+ - Load Hamilton's Bahrain 2024 Q telemetry from the prefetched FastF1
20
+ cache (G1 + task 2.10 use the same source).
21
+ - Aggregate to 1 Hz mini-sectors.
22
+ - Split: laps 1-3 = TTM context window, laps 4-5 = holdout (the next
23
+ HORIZON=30 seconds after the lap-3 trailing edge).
24
+ - TTM forecast: TtmForecaster.forecast() over the context window.
25
+ - Seasonal-naive baseline: repeat the last context-window value
26
+ (tail-anchor) for HORIZON steps. This is the same definition the
27
+ naive forecast in pipelines/telemetry_to_log.py uses (the G4
28
+ baseline + the demo baseline are the same code path).
29
+ - MAE = mean(|forecast[step, ch] - actual[step, ch]|) over the
30
+ holdout. Compare per-channel across the two forecasters.
31
+
32
+ Run from app/backend/:
33
+ .venv/Scripts/python -m apex.pipelines.g4_mae_bakeoff
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import sys
40
+ import time
41
+ from dataclasses import asdict, dataclass
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+
46
+ from apex.shared.contracts import CHANNEL_COUNT, CHANNELS, HORIZON, channel_index
47
+ from apex.ttm.forecast import aggregate_to_1hz, shape_ttm_input
48
+
49
+ SEED = 42
50
+ EVAL_CHANNELS = ("speed_mps", "long_g")
51
+
52
+ REPO_ROOT = Path(__file__).resolve().parents[4]
53
+ FASTF1_CACHE = REPO_ROOT / "app" / "backend" / ".fastf1_cache"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ChannelResult:
58
+ channel: str
59
+ ttm_mae: float
60
+ naive_mae: float
61
+ available_in_fastf1: bool
62
+
63
+ @property
64
+ def mae_delta(self) -> float:
65
+ """Negative means TTM wins (lower MAE)."""
66
+ return self.ttm_mae - self.naive_mae
67
+
68
+ @property
69
+ def ttm_wins(self) -> bool:
70
+ return self.ttm_mae < self.naive_mae
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class BakeoffResult:
75
+ channels: tuple[ChannelResult, ...]
76
+ holdout_steps: int
77
+ context_steps: int
78
+ seed: int
79
+ fastf1_source: str
80
+ ttm_load_seconds: float
81
+ ttm_forward_ms: float
82
+
83
+ @property
84
+ def pass_(self) -> bool:
85
+ """G4 passes if TTM wins on speed_mps. long_g is absent from
86
+ FastF1 per pre-mortem row 62 and reports as not-comparable; the
87
+ gate decision rides on speed_mps alone.
88
+ """
89
+ for ch in self.channels:
90
+ if ch.channel == "speed_mps":
91
+ return ch.ttm_wins
92
+ return False
93
+
94
+
95
+ def load_hamilton_laps(lap_indices: tuple[int, ...]) -> tuple[np.ndarray, int]:
96
+ """Pull Hamilton's Bahrain 2024 Q laps from the cache and concatenate.
97
+
98
+ Returns (telemetry (T, CHANNEL_COUNT) float32, native sample rate Hz).
99
+ The native rate is derived from the median `Date` delta per row; the
100
+ G1 smoke's `source_hz=50` was off by ~12x (FastF1 car_data is ~4 Hz
101
+ in practice, not 50 Hz). See `logs/day-04-g4.md` for the audit.
102
+ """
103
+ import fastf1
104
+ import pandas as pd
105
+
106
+ fastf1.Cache.enable_cache(str(FASTF1_CACHE))
107
+ session = fastf1.get_session(2024, "Bahrain", "Q")
108
+ session.load(telemetry=True, laps=True, weather=False)
109
+
110
+ all_laps = session.laps.pick_drivers("44")
111
+ parts = []
112
+ for i in lap_indices:
113
+ lap = all_laps.iloc[i]
114
+ car_data = lap.get_car_data()
115
+ parts.append(car_data)
116
+ car = pd.concat(parts, ignore_index=True)
117
+
118
+ # Derive the actual sample rate from the `Date` column. FastF1 ships
119
+ # variable-rate samples; we round to the nearest integer Hz so the
120
+ # aggregator's reshape contract holds.
121
+ if "Date" in car.columns:
122
+ deltas = car["Date"].diff().dt.total_seconds().dropna()
123
+ median_dt = float(deltas.median())
124
+ derived_hz = max(1, round(1.0 / median_dt))
125
+ else:
126
+ derived_hz = 4 # documented fallback
127
+
128
+ fastf1_map = {
129
+ "throttle_pct": "Throttle",
130
+ "brake_pa": "Brake",
131
+ "rpm": "RPM",
132
+ "speed_mps": "Speed",
133
+ "gear": "nGear",
134
+ }
135
+ T = len(car)
136
+ out = np.zeros((T, CHANNEL_COUNT), dtype=np.float32)
137
+ for our_name, ff1_name in fastf1_map.items():
138
+ if ff1_name not in car.columns:
139
+ continue
140
+ i = channel_index(our_name)
141
+ col = car[ff1_name].to_numpy(dtype=np.float32)
142
+ if our_name == "speed_mps":
143
+ col = col / 3.6
144
+ if our_name == "brake_pa":
145
+ col = col.astype(np.float32) * 3.5e6
146
+ out[:, i] = col
147
+
148
+ return out, derived_hz
149
+
150
+
151
+ def seasonal_naive_forecast(context: np.ndarray) -> np.ndarray:
152
+ """Tail-anchored naive baseline: repeat the last context row HORIZON times.
153
+
154
+ This is the same baseline pipelines/telemetry_to_log.py uses in 'naive'
155
+ mode, so G4 and the demo pipeline share a single naive-forecast definition.
156
+ """
157
+ return np.repeat(context[-1:], HORIZON, axis=0).astype(np.float64, copy=False)
158
+
159
+
160
+ def compute_per_channel_mae(
161
+ forecast: np.ndarray,
162
+ actual: np.ndarray,
163
+ channels: tuple[str, ...],
164
+ ) -> dict[str, float]:
165
+ """forecast, actual: shape (HORIZON, CHANNEL_COUNT). Returns per-channel MAE."""
166
+ out: dict[str, float] = {}
167
+ for ch in channels:
168
+ i = channel_index(ch)
169
+ residual = np.abs(forecast[:, i] - actual[:, i])
170
+ out[ch] = float(residual.mean())
171
+ return out
172
+
173
+
174
+ def run_bakeoff() -> BakeoffResult:
175
+ np.random.seed(SEED)
176
+
177
+ # ---- Load laps 0-2 (context) + 3-4 (holdout) ----------------------
178
+ # FastF1 lap indices are zero-based; "laps 4-5" in plan-speak = idx 3-4.
179
+ print("[1/5] loading Hamilton Bahrain 2024 Q laps 1-3 + 4-5 from cache ...")
180
+ context_raw, source_hz = load_hamilton_laps((0, 1, 2))
181
+ holdout_raw, _ = load_hamilton_laps((3, 4))
182
+ print(f" context T={context_raw.shape[0]} rows @ {source_hz} Hz")
183
+ print(f" holdout T={holdout_raw.shape[0]} rows @ {source_hz} Hz")
184
+
185
+ # ---- Aggregate both to 1 Hz mini-sectors --------------------------
186
+ print("[2/5] aggregating to 1 Hz ...")
187
+ context_1hz = aggregate_to_1hz(context_raw, source_hz=source_hz)
188
+ holdout_1hz = aggregate_to_1hz(holdout_raw, source_hz=source_hz)
189
+ print(f" context 1Hz T={context_1hz.shape[0]}; holdout 1Hz T={holdout_1hz.shape[0]}")
190
+
191
+ if holdout_1hz.shape[0] < HORIZON:
192
+ raise RuntimeError(
193
+ f"holdout has {holdout_1hz.shape[0]} 1Hz rows; need at least {HORIZON} for the bake-off."
194
+ )
195
+
196
+ holdout_window = holdout_1hz[:HORIZON].astype(np.float64)
197
+
198
+ # ---- TTM zero-shot forecast --------------------------------------
199
+ print("[3/5] TTM-r2 zero-shot forecast ...")
200
+ t0 = time.time()
201
+ from apex.ttm.forecast import TtmForecaster
202
+ forecaster = TtmForecaster()
203
+ ttm_load_seconds = time.time() - t0
204
+ print(f" TTM-r2 loaded in {ttm_load_seconds:.2f}s; context_length={forecaster.context_length}")
205
+
206
+ # We want forecaster's full path (aggregate -> shape -> forward) on the
207
+ # CONTEXT telemetry, but we already aggregated. Re-pack the aggregated
208
+ # context as if it were 1 Hz raw (no further aggregation needed).
209
+ t0 = time.time()
210
+ ttm_pred_1hzraw = forecaster.forecast(context_1hz.astype(np.float32), source_hz=1)
211
+ ttm_forward_ms = (time.time() - t0) * 1000.0
212
+ ttm_forecast = ttm_pred_1hzraw[0].astype(np.float64)
213
+ print(f" forward {ttm_forward_ms:.1f} ms; output shape={ttm_pred_1hzraw.shape}")
214
+
215
+ # ---- Seasonal-naive baseline -------------------------------------
216
+ print("[4/5] seasonal-naive baseline ...")
217
+ naive_forecast = seasonal_naive_forecast(context_1hz)
218
+ print(f" naive forecast shape={naive_forecast.shape}")
219
+
220
+ # ---- Per-channel MAE ---------------------------------------------
221
+ print("[5/5] computing per-channel MAE ...")
222
+ ttm_mae = compute_per_channel_mae(ttm_forecast, holdout_window, EVAL_CHANNELS)
223
+ naive_mae = compute_per_channel_mae(naive_forecast, holdout_window, EVAL_CHANNELS)
224
+
225
+ channel_results: list[ChannelResult] = []
226
+ for ch in EVAL_CHANNELS:
227
+ available = ch in ("throttle_pct", "brake_pa", "rpm", "speed_mps", "gear")
228
+ channel_results.append(
229
+ ChannelResult(
230
+ channel=ch,
231
+ ttm_mae=ttm_mae[ch],
232
+ naive_mae=naive_mae[ch],
233
+ available_in_fastf1=available,
234
+ )
235
+ )
236
+
237
+ return BakeoffResult(
238
+ channels=tuple(channel_results),
239
+ holdout_steps=HORIZON,
240
+ context_steps=int(context_1hz.shape[0]),
241
+ seed=SEED,
242
+ fastf1_source="Hamilton 2024 Bahrain Q, laps 1-3 context / 4-5 holdout",
243
+ ttm_load_seconds=round(ttm_load_seconds, 2),
244
+ ttm_forward_ms=round(ttm_forward_ms, 1),
245
+ )
246
+
247
+
248
+ def render_report(result: BakeoffResult) -> str:
249
+ lines: list[str] = []
250
+ lines.append("=" * 72)
251
+ lines.append("G4 - TTM zero-shot vs seasonal-naive MAE bake-off")
252
+ lines.append("=" * 72)
253
+ lines.append(f"source: {result.fastf1_source}")
254
+ lines.append(f"seed: {result.seed}; horizon: {result.holdout_steps} steps @ 1 Hz")
255
+ lines.append(f"context: {result.context_steps} 1 Hz steps")
256
+ lines.append(f"TTM load: {result.ttm_load_seconds:.2f}s; forward: {result.ttm_forward_ms:.1f} ms")
257
+ lines.append("")
258
+ lines.append(f"{'channel':<14} {'TTM MAE':>12} {'naive MAE':>12} {'delta':>12} verdict")
259
+ lines.append("-" * 72)
260
+ for ch in result.channels:
261
+ if not ch.available_in_fastf1:
262
+ verdict = "n/a (FastF1 channel absent per pre-mortem row 62)"
263
+ lines.append(
264
+ f"{ch.channel:<14} {'-':>12} {'-':>12} {'-':>12} {verdict}"
265
+ )
266
+ continue
267
+ verdict = "TTM wins" if ch.ttm_wins else "naive wins"
268
+ lines.append(
269
+ f"{ch.channel:<14} {ch.ttm_mae:>12.4f} {ch.naive_mae:>12.4f} "
270
+ f"{ch.mae_delta:>12.4f} {verdict}"
271
+ )
272
+ lines.append("")
273
+ verdict = "PASS" if result.pass_ else "FAIL"
274
+ lines.append(f"VERDICT (G4 floor: TTM wins on speed_mps): {verdict}")
275
+ lines.append("=" * 72)
276
+ return "\n".join(lines)
277
+
278
+
279
+ def main() -> int:
280
+ result = run_bakeoff()
281
+ report = render_report(result)
282
+ print(report)
283
+
284
+ out_dir = REPO_ROOT / "logs"
285
+ out_dir.mkdir(exist_ok=True)
286
+ (out_dir / "day-04-g4-numbers.json").write_text(
287
+ json.dumps(
288
+ {
289
+ "channels": [asdict(c) for c in result.channels],
290
+ "holdout_steps": result.holdout_steps,
291
+ "context_steps": result.context_steps,
292
+ "seed": result.seed,
293
+ "fastf1_source": result.fastf1_source,
294
+ "ttm_load_seconds": result.ttm_load_seconds,
295
+ "ttm_forward_ms": result.ttm_forward_ms,
296
+ "pass": result.pass_,
297
+ },
298
+ indent=2,
299
+ ),
300
+ encoding="utf-8",
301
+ )
302
+ print(f"\nwrote numbers JSON to {out_dir / 'day-04-g4-numbers.json'}")
303
+ return 0 if result.pass_ else 1
304
+
305
+
306
+ if __name__ == "__main__":
307
+ sys.exit(main())
308
+
309
+
310
+ __all__ = [
311
+ "BakeoffResult",
312
+ "ChannelResult",
313
+ "compute_per_channel_mae",
314
+ "load_hamilton_laps",
315
+ "main",
316
+ "render_report",
317
+ "run_bakeoff",
318
+ "seasonal_naive_forecast",
319
+ ]
apex/pipelines/sarah_e2e.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end Sarah pipeline (Phase 3 task 3.5).
2
+
3
+ Wires the full Day-6 Vinh-lane backend path:
4
+ Sarah CSV + COA + debrief
5
+ -> load_telemetry_csv
6
+ -> build_ttm_input (tile COA simultaneity flag)
7
+ -> validate_forecast (V1 NumPy floor)
8
+ -> Guardian.audit (BYOC rule registry)
9
+ -> Narrator.narrate (assemble CoachingReport)
10
+
11
+ Output: a CoachingReport JSON-serializable dict matching the canonical
12
+ frontend contract at app/shared/types.ts L436. Provenance footer carries
13
+ non-None audit_id (Software Lead fix #9); every citation resolves to
14
+ the input CoaParseResult (no hallucinated FIA Articles per project
15
+ compliance).
16
+
17
+ This is the G6 reproducibility surface. The /api/analyze production
18
+ route on the frontend will call this same pipeline assembly logic.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import asdict, is_dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from apex.guardian.audit import Guardian
31
+ from apex.instruct.coa_parser import CoaParseResult, parse_coa_json
32
+ from apex.instruct.narrator import CoachingReport, Narrator, NarratorInputs
33
+ from apex.physics.validator import ToleranceBands, validate_forecast
34
+ from apex.pipelines.telemetry_to_log import load_telemetry_csv
35
+ from apex.shared.contracts import (
36
+ HORIZON,
37
+ PhysicsViolationLog,
38
+ build_ttm_input,
39
+ channel_index,
40
+ )
41
+
42
+
43
+ def _coerce_to_horizon(telemetry: np.ndarray) -> np.ndarray:
44
+ """Take the last HORIZON rows of telemetry as the forecast input.
45
+
46
+ Sarah's 5-lap fixture has 300 rows; the validator + narrator are
47
+ horizon-scoped. The naive forecast for G6 is "predict the next 30
48
+ seconds look like the most recent 30 seconds" (seasonal-naive
49
+ baseline per G4 framing); G9 will swap in three-track fusion.
50
+ """
51
+ if telemetry.shape[0] >= HORIZON:
52
+ return telemetry[-HORIZON:].astype(np.float64, copy=True)
53
+ pad = np.repeat(telemetry[-1:], HORIZON - telemetry.shape[0], axis=0)
54
+ return np.concatenate([telemetry, pad], axis=0).astype(np.float64, copy=True)
55
+
56
+
57
+ def run_sarah_e2e(
58
+ *,
59
+ telemetry_csv: Path | str,
60
+ coa_json: Path | str,
61
+ debrief_path: Path | str | None = None,
62
+ mu: float = 1.2,
63
+ wheelbase_m: float = 2.7,
64
+ ) -> CoachingReport:
65
+ """End-to-end Sarah pipeline. Returns a CoachingReport dataclass.
66
+
67
+ Use `coaching_report_to_dict()` to serialize for the wire.
68
+ """
69
+ telemetry = load_telemetry_csv(Path(telemetry_csv))
70
+ coa = parse_coa_json(Path(coa_json))
71
+ debrief = Path(debrief_path).read_text(encoding="utf-8") if debrief_path else ""
72
+
73
+ forecast = _coerce_to_horizon(telemetry)
74
+ # Tile the COA simultaneity flag into the forecast's coa_overlap_flag
75
+ # channel via the single-source-of-truth adapter.
76
+ batched = forecast[None, :, :]
77
+ tiled = build_ttm_input(batched, simultaneity_permitted=coa.simultaneity_permitted)
78
+ forecast = tiled[0]
79
+
80
+ simultaneity_channel = forecast[:, channel_index("coa_overlap_flag")]
81
+ log: PhysicsViolationLog = validate_forecast(
82
+ forecast,
83
+ mu=mu,
84
+ wheelbase_m=wheelbase_m,
85
+ simultaneity_channel=simultaneity_channel,
86
+ bands=ToleranceBands.for_1hz_aggregation(),
87
+ )
88
+
89
+ audit = Guardian().audit(violation_log=log, coa=coa)
90
+ narrator = Narrator()
91
+ inputs = NarratorInputs(
92
+ forecast=forecast,
93
+ coa=coa,
94
+ violation_log=log,
95
+ guardian_audit=audit,
96
+ debrief=debrief,
97
+ )
98
+ out = narrator.narrate(inputs)
99
+ return out.coaching_report
100
+
101
+
102
+ def _dataclass_to_dict(obj: Any) -> Any:
103
+ """Recursive dataclass + tuple -> JSON-serializable conversion."""
104
+ if is_dataclass(obj) and not isinstance(obj, type):
105
+ return {k: _dataclass_to_dict(v) for k, v in asdict(obj).items()}
106
+ if isinstance(obj, (tuple, list)):
107
+ return [_dataclass_to_dict(v) for v in obj]
108
+ if isinstance(obj, dict):
109
+ return {k: _dataclass_to_dict(v) for k, v in obj.items()}
110
+ return obj
111
+
112
+
113
+ def coaching_report_to_dict(report: CoachingReport) -> dict[str, Any]:
114
+ return _dataclass_to_dict(report)
115
+
116
+
117
+ def coaching_report_to_json(report: CoachingReport, *, indent: int = 2) -> str:
118
+ return json.dumps(coaching_report_to_dict(report), indent=indent, sort_keys=False)
119
+
120
+
121
+ __all__ = [
122
+ "coaching_report_to_dict",
123
+ "coaching_report_to_json",
124
+ "run_sarah_e2e",
125
+ ]
apex/pipelines/telemetry_to_log.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end pipeline: telemetry CSV -> forecast -> validator -> text log.
2
+
3
+ Phase 2 Day 4 task 2.9. Runs the Day-4 demo flow with two forecast modes:
4
+
5
+ - 'naive': telemetry IS the forecast (edge-padded / truncated to
6
+ HORIZON). Cheap; no TTM model load required. Doubles as the
7
+ seasonal-naive baseline for the G4 bake-off (task 2.11).
8
+ - 'ttm': frozen TTM-r2 zero-shot forecast via TtmForecaster. Heavy;
9
+ requires the .venv with torch + tsfm_public + ~600MB HF download.
10
+ Integration coverage at tests/test_ttm_integration.py (task 2.10).
11
+
12
+ The script is callable two ways:
13
+ 1. As a library: `from apex.pipelines.telemetry_to_log import run_pipeline`
14
+ returns a PipelineResult with the forecast tensor + violation log +
15
+ CoA parse result for provenance assembly.
16
+ 2. As a CLI:
17
+ `python -m apex.pipelines.telemetry_to_log --telemetry sarah.csv --coa sarah.json --mode naive`
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import sys
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Literal
27
+
28
+ import numpy as np
29
+
30
+ from apex.instruct.coa_parser import CoaParseResult, parse_coa_json
31
+ from apex.physics.validator import ToleranceBands, validate_forecast
32
+ from apex.shared.contracts import (
33
+ CHANNEL_COUNT,
34
+ CHANNELS,
35
+ HORIZON,
36
+ PhysicsViolationLog,
37
+ build_ttm_input,
38
+ channel_index,
39
+ )
40
+ from apex.ttm.forecast import shape_ttm_input
41
+
42
+ ForecastMode = Literal["naive", "ttm"]
43
+ _KNOWN_MODES: tuple[ForecastMode, ...] = ("naive", "ttm")
44
+
45
+
46
+ # ---- Result dataclass ---------------------------------------------------
47
+
48
+ @dataclass(frozen=True)
49
+ class PipelineResult:
50
+ """Output of `run_pipeline`. Frozen so the Phase 3 provenance assembler
51
+ can pass this object around without worrying about downstream mutation.
52
+ """
53
+
54
+ coa: CoaParseResult
55
+ forecast_tensor: np.ndarray # (HORIZON, CHANNEL_COUNT)
56
+ violation_log: PhysicsViolationLog
57
+ forecast_mode: ForecastMode
58
+
59
+
60
+ # ---- I/O ----------------------------------------------------------------
61
+
62
+ def load_telemetry_csv(path: Path) -> np.ndarray:
63
+ """Load a Sarah-style telemetry CSV into a (T, CHANNEL_COUNT) array.
64
+
65
+ Skips lines beginning with `#` (the fictional-persona watermark + the
66
+ inline comments documenting the fixture purpose) and the header row.
67
+ Channel order in the CSV must match `shared.contracts.CHANNELS`.
68
+ """
69
+ path = Path(path)
70
+ if not path.exists():
71
+ raise FileNotFoundError(f"telemetry CSV not found: {path}")
72
+
73
+ rows: list[list[float]] = []
74
+ with path.open("r", encoding="utf-8") as f:
75
+ for line in f:
76
+ stripped = line.strip()
77
+ if not stripped or stripped.startswith("#"):
78
+ continue
79
+ # Header row: starts with the first channel name
80
+ if stripped.startswith(CHANNELS[0]):
81
+ continue
82
+ rows.append([float(x) for x in stripped.split(",")])
83
+
84
+ arr = np.asarray(rows, dtype=np.float64)
85
+ if arr.ndim != 2 or arr.shape[1] != CHANNEL_COUNT:
86
+ raise ValueError(
87
+ f"telemetry CSV shape mismatch: expected (T, {CHANNEL_COUNT}); "
88
+ f"got {arr.shape} from {path}"
89
+ )
90
+ return arr
91
+
92
+
93
+ # ---- Forecast modes -----------------------------------------------------
94
+
95
+ def _naive_forecast(telemetry: np.ndarray) -> np.ndarray:
96
+ """Seasonal-naive forecast: tile/truncate telemetry to (HORIZON, CHANNEL_COUNT).
97
+
98
+ Edge-pads short telemetry by repeating the last row (tail-anchor:
99
+ the seasonal-naive prediction "next 30 steps look like the most
100
+ recent telemetry"). Truncates long telemetry to the last HORIZON
101
+ rows. The result is a (HORIZON, CHANNEL_COUNT) array, the V1
102
+ validator's expected input shape.
103
+ """
104
+ T = telemetry.shape[0]
105
+ if T >= HORIZON:
106
+ return telemetry[-HORIZON:].astype(np.float64, copy=True)
107
+ pad = np.repeat(telemetry[-1:], HORIZON - T, axis=0)
108
+ return np.concatenate([telemetry, pad], axis=0).astype(np.float64, copy=True)
109
+
110
+
111
+ def _ttm_forecast(telemetry: np.ndarray) -> np.ndarray:
112
+ """Frozen TTM-r2 zero-shot forecast.
113
+
114
+ Loads the model on every call. Pipelines that drive many forecasts
115
+ should hold a TtmForecaster instance directly rather than going
116
+ through this function.
117
+ """
118
+ from apex.ttm.forecast import TtmForecaster
119
+
120
+ forecaster = TtmForecaster()
121
+ out = forecaster.forecast(telemetry, source_hz=1)
122
+ # forecaster.forecast returns (1, HORIZON, CHANNEL_COUNT); strip batch
123
+ return out[0].astype(np.float64, copy=False)
124
+
125
+
126
+ # ---- Pipeline driver ----------------------------------------------------
127
+
128
+ def run_pipeline(
129
+ *,
130
+ telemetry_csv: Path | str,
131
+ coa_json: Path | str,
132
+ forecast_mode: ForecastMode,
133
+ mu: float = 1.2,
134
+ wheelbase_m: float = 2.7,
135
+ out_path: Path | str | None = None,
136
+ ) -> PipelineResult:
137
+ """Run the Day-4 end-to-end pipeline and return a PipelineResult.
138
+
139
+ Optional: when `out_path` is provided, the violation log is also
140
+ written to disk in the engine-agnostic text format.
141
+ """
142
+ if forecast_mode not in _KNOWN_MODES:
143
+ raise ValueError(
144
+ f"forecast_mode must be one of {_KNOWN_MODES}; got {forecast_mode!r}."
145
+ )
146
+
147
+ telemetry = load_telemetry_csv(Path(telemetry_csv))
148
+ coa = parse_coa_json(Path(coa_json))
149
+
150
+ if forecast_mode == "naive":
151
+ forecast = _naive_forecast(telemetry)
152
+ else:
153
+ forecast = _ttm_forecast(telemetry)
154
+
155
+ # Tile the COA simultaneity flag into the forecast's coa_overlap_flag
156
+ # channel. build_ttm_input expects (B, HORIZON, CHANNEL_COUNT); we add
157
+ # then strip the batch axis so the validator (which is per-forecast,
158
+ # not batched) gets back its (HORIZON, CHANNEL_COUNT) contract.
159
+ batched = forecast[None, :, :]
160
+ tiled = build_ttm_input(batched, simultaneity_permitted=coa.simultaneity_permitted)
161
+ forecast = tiled[0]
162
+
163
+ simultaneity_channel = forecast[:, channel_index("coa_overlap_flag")]
164
+ log = validate_forecast(
165
+ forecast,
166
+ mu=mu,
167
+ wheelbase_m=wheelbase_m,
168
+ simultaneity_channel=simultaneity_channel,
169
+ bands=ToleranceBands.for_1hz_aggregation(),
170
+ )
171
+
172
+ if out_path is not None:
173
+ Path(out_path).write_text(log.to_text(), encoding="utf-8")
174
+
175
+ return PipelineResult(
176
+ coa=coa,
177
+ forecast_tensor=forecast,
178
+ violation_log=log,
179
+ forecast_mode=forecast_mode,
180
+ )
181
+
182
+
183
+ # ---- CLI ----------------------------------------------------------------
184
+
185
+ def _build_arg_parser() -> argparse.ArgumentParser:
186
+ p = argparse.ArgumentParser(
187
+ prog="apex.pipelines.telemetry_to_log",
188
+ description="Telemetry CSV -> forecast -> validator -> text violation log.",
189
+ )
190
+ p.add_argument("--telemetry", required=True, type=Path,
191
+ help="path to a Sarah-style telemetry CSV in CHANNELS column order")
192
+ p.add_argument("--coa", required=True, type=Path,
193
+ help="path to a Sarah-style COA JSON stub")
194
+ p.add_argument("--mode", choices=_KNOWN_MODES, default="naive",
195
+ help="forecast mode: 'naive' (seasonal-naive baseline) "
196
+ "or 'ttm' (frozen TTM-r2 zero-shot, heavy)")
197
+ p.add_argument("--out", type=Path, default=None,
198
+ help="optional output path for the text violation log")
199
+ p.add_argument("--mu", type=float, default=1.2,
200
+ help="nominal friction coefficient (constant-mu V1)")
201
+ p.add_argument("--wheelbase", type=float, default=2.7,
202
+ help="vehicle wheelbase in meters")
203
+ return p
204
+
205
+
206
+ def main(argv: list[str] | None = None) -> int:
207
+ args = _build_arg_parser().parse_args(argv)
208
+ result = run_pipeline(
209
+ telemetry_csv=args.telemetry,
210
+ coa_json=args.coa,
211
+ forecast_mode=args.mode,
212
+ mu=args.mu,
213
+ wheelbase_m=args.wheelbase,
214
+ out_path=args.out,
215
+ )
216
+ text = result.violation_log.to_text()
217
+ print(text)
218
+ print(
219
+ f"driver={result.coa.driver_id} mode={result.forecast_mode} "
220
+ f"fcvr={result.violation_log.fcvr():.4f} "
221
+ f"violations={len(result.violation_log.records)}",
222
+ file=sys.stderr,
223
+ )
224
+ return 0
225
+
226
+
227
+ if __name__ == "__main__":
228
+ sys.exit(main())
229
+
230
+
231
+ __all__ = [
232
+ "PipelineResult",
233
+ "ForecastMode",
234
+ "load_telemetry_csv",
235
+ "run_pipeline",
236
+ "main",
237
+ ]
apex/schemas.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic v2 request + response schemas for the APEX backend (wave-48).
2
+
3
+ OVERRIDE quality-bar audit close-out per `feedback_three_brain_review_
4
+ pattern.md`: the OVERRIDE 10-tool fully-WIRED competitor ships Pydantic
5
+ v2 typed transit objects on every route. This module replicates that
6
+ posture on the APEX backend so FastAPI auto-validates request bodies +
7
+ auto-serializes responses against typed schemas.
8
+
9
+ Convention:
10
+ - Request models suffixed `Req` (e.g. `AuditLogReq`).
11
+ - Response models suffixed `Resp`.
12
+ - Discriminated unions use `model_config` literal-tag on the wire
13
+ when the frontend type uses a discriminated-union (matches the
14
+ `TSPulseAnomalyState` shape on `app/shared/types.ts`).
15
+ - Frozen models via `model_config = ConfigDict(frozen=True)` so
16
+ constructed instances cannot drift mid-handler.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Literal, Optional
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+
26
+ class FrozenModel(BaseModel):
27
+ """Base class for immutable response models."""
28
+
29
+ model_config = ConfigDict(frozen=True, extra="forbid")
30
+
31
+
32
+ # ---- Healthz ---------------------------------------------------------
33
+
34
+
35
+ class HealthzResp(FrozenModel):
36
+ status: Literal["ok"]
37
+
38
+
39
+ # ---- Audit log -------------------------------------------------------
40
+
41
+
42
+ class AuditLogReq(BaseModel):
43
+ """Loose audit-log payload; backend accepts arbitrary verdict shapes."""
44
+
45
+ model_config = ConfigDict(extra="allow")
46
+
47
+
48
+ class AuditLogResp(FrozenModel):
49
+ persisted: bool
50
+ line_index: int
51
+ file_path: str
52
+
53
+
54
+ # ---- What-if replay --------------------------------------------------
55
+
56
+
57
+ class WhatIfReplayReq(BaseModel):
58
+ baseline_fixture_id: str = Field(..., min_length=1, max_length=128)
59
+ mutation_key: str = Field(..., min_length=1, max_length=128)
60
+
61
+
62
+ class WhatIfReplayResp(FrozenModel):
63
+ mutated_fixture: dict
64
+ replayed_violation_log: str
65
+ schema_version: int
66
+ protocol_version: int
67
+
68
+
69
+ # ---- Session context -------------------------------------------------
70
+
71
+
72
+ class SessionTile(FrozenModel):
73
+ key: str
74
+ label: str
75
+ value: str
76
+ detail: str
77
+ severity: Literal["ok", "monitor", "critical"]
78
+
79
+
80
+ class SessionContextResp(FrozenModel):
81
+ tiles: list[SessionTile]
82
+ fetched_at_iso: str
83
+
84
+
85
+ # ---- Orchestration trace ---------------------------------------------
86
+
87
+
88
+ class OrchestrationNode(FrozenModel):
89
+ id: str
90
+ label: str
91
+ status: str
92
+ elapsed_ms: float
93
+
94
+
95
+ class OrchestrationResp(FrozenModel):
96
+ engine: str
97
+ trace_id: str
98
+ nodes: list[OrchestrationNode]
99
+ total_ms: int
100
+ swap_point: str
101
+ compute_ms: int
102
+
103
+
104
+ # ---- TSPulse anomaly -------------------------------------------------
105
+
106
+
107
+ class TSPulseStateClean(FrozenModel):
108
+ status: Literal["clean"]
109
+ window_index: int
110
+ score: float
111
+ threshold_p95: float
112
+ detection_ms: int
113
+
114
+
115
+ class TSPulseStateAnomaly(FrozenModel):
116
+ status: Literal["anomaly"]
117
+ window_index: int
118
+ score: float
119
+ threshold_p95: float
120
+ affected_bands: list[Literal["dc", "low", "mid", "high"]]
121
+ detection_ms: int
122
+
123
+
124
+ class TSPulseStateError(FrozenModel):
125
+ status: Literal["error"]
126
+ message: str
127
+
128
+
129
+ TSPulseState = TSPulseStateClean | TSPulseStateAnomaly | TSPulseStateError
130
+
131
+
132
+ class TSPulseResp(FrozenModel):
133
+ engine: Literal[
134
+ "tspulse-v7-canned-fallback",
135
+ "tspulse-v7-real",
136
+ "tspulse-r1-anomaly",
137
+ "tspulse-stub",
138
+ ]
139
+ compute_ms: int
140
+ state: TSPulseState
141
+ swap_point: str
142
+
143
+
144
+ # ---- Analyze ---------------------------------------------------------
145
+
146
+
147
+ class AnalyzeReq(BaseModel):
148
+ telemetry_csv_path: str = Field(..., min_length=1)
149
+ coa_json_path: str = Field(..., min_length=1)
150
+ debrief_path: Optional[str] = None
151
+
152
+
153
+ class AnalyzeTraceStep(FrozenModel):
154
+ node: str
155
+ status: str
156
+ duration_ms: float
157
+ detail: str
158
+
159
+
160
+ class AnalyzeResp(FrozenModel):
161
+ coaching_report: dict
162
+ trace: list[AnalyzeTraceStep]
163
+ swap_point: str
164
+
165
+
166
+ __all__ = [
167
+ "AnalyzeReq",
168
+ "AnalyzeResp",
169
+ "AnalyzeTraceStep",
170
+ "AuditLogReq",
171
+ "AuditLogResp",
172
+ "FrozenModel",
173
+ "HealthzResp",
174
+ "OrchestrationNode",
175
+ "OrchestrationResp",
176
+ "SessionContextResp",
177
+ "SessionTile",
178
+ "TSPulseResp",
179
+ "TSPulseState",
180
+ "TSPulseStateAnomaly",
181
+ "TSPulseStateClean",
182
+ "TSPulseStateError",
183
+ "WhatIfReplayReq",
184
+ "WhatIfReplayResp",
185
+ ]
apex/server.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI HTTP wrapper for the APEX backend (Phase 5 task 5.2).
2
+
3
+ Exposes:
4
+ - POST /api/audit-log (task 4.M3a)
5
+ - POST /api/what-if-replay (task 4.M3b)
6
+ - GET /api/session-context (task 4.M3c)
7
+ - GET /api/orchestration (wave-47 cascade-#53; frontend V14 wire-flip)
8
+ - POST /api/analyze (Sarah end-to-end pipeline; JSON file paths)
9
+ - POST /api/analyze-upload (wave-48 multipart fix; driver-supplied files)
10
+ - GET /api/tspulse/anomaly (wave-48 Tier-2; IBM TSPulse r1 polyphase anomaly head)
11
+ - GET /healthz (container readiness probe)
12
+
13
+ Deploy target: any Docker host (Modal / Fly.io / Vercel functions /
14
+ container registry). Backed by the deterministic Python modules
15
+ landed in Phase 3 + Phase 4; HTTP surface is a thin wrapper.
16
+
17
+ Production routing per D-052: Stephen-side `/api/openrouter-stream`
18
+ remains the production Granite 4.1 8B path (frontend route at Vercel
19
+ Edge). This backend service is the Vinh-lane swap-target for the
20
+ LangGraph runtime + Stream M.3 endpoints + analyze pipeline.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import shutil
28
+ import tempfile
29
+ from pathlib import Path
30
+ from typing import Any, Final
31
+
32
+ from fastapi import FastAPI, File, HTTPException, Request, UploadFile
33
+ from fastapi.middleware.cors import CORSMiddleware
34
+
35
+ from apex.instruct.narrator import Narrator
36
+ from apex.instruct.openrouter_generator import build_openrouter_generator
37
+ from apex.observability import setup_observability
38
+ from apex.orchestration.audit_log import (
39
+ AuditLogLineTooLarge,
40
+ AuditLogStore,
41
+ )
42
+ from apex.orchestration.langgraph_runtime import run_langgraph
43
+ from apex.orchestration.session_context import SessionContextProvider
44
+ from apex.orchestration.what_if_replay import (
45
+ UnknownFixtureError,
46
+ UnknownMutationError,
47
+ run_what_if_replay,
48
+ )
49
+ from apex.pipelines.sarah_e2e import coaching_report_to_dict
50
+ from apex.pipelines.telemetry_to_log import load_telemetry_csv
51
+ from apex.schemas import (
52
+ AuditLogResp,
53
+ HealthzResp,
54
+ OrchestrationResp,
55
+ SessionContextResp,
56
+ TSPulseResp,
57
+ WhatIfReplayResp,
58
+ )
59
+ from apex.tspulse import detect_anomaly
60
+
61
+ # ---- Upload constraints ------------------------------------------------
62
+ # wave-48 multipart fix: /api/analyze-upload accepts driver-supplied
63
+ # telemetry + COA + debrief as multipart files. Caps are deliberately
64
+ # tight to keep the CPU-only HF Spaces deploy responsive + within the
65
+ # free-tier RAM budget.
66
+
67
+ _MAX_TELEMETRY_BYTES: int = 10 * 1024 * 1024 # 10 MiB CSV
68
+ _MAX_COA_BYTES: int = 1 * 1024 * 1024 # 1 MiB JSON
69
+ _MAX_DEBRIEF_BYTES: int = 256 * 1024 # 256 KiB markdown
70
+ _ALLOWED_TELEMETRY_SUFFIX: set[str] = {".csv"}
71
+ _ALLOWED_COA_SUFFIX: set[str] = {".json"}
72
+ _ALLOWED_DEBRIEF_SUFFIX: set[str] = {".md", ".txt"}
73
+
74
+ # ---- Singletons -------------------------------------------------------
75
+
76
+ AUDIT_LOG_PATH = Path(
77
+ os.environ.get(
78
+ "APEX_AUDIT_LOG_PATH",
79
+ str(Path.home() / ".apex" / "audit-log.jsonl"),
80
+ )
81
+ )
82
+ _audit_store = AuditLogStore(file_path=AUDIT_LOG_PATH)
83
+ _session_provider = SessionContextProvider()
84
+
85
+
86
+ def _build_live_narrator() -> Narrator | None:
87
+ """Construct a Narrator with the OpenRouter generator wired in.
88
+
89
+ Returns None when the env is missing prerequisites; callers swap to
90
+ the deterministic floor in that case. Idempotent: safe to call once
91
+ per request without paying the cost of repeated env reads in hot
92
+ paths because the underlying httpx client is reconstructed on each
93
+ `_generate()` call anyway.
94
+ """
95
+ generator = build_openrouter_generator()
96
+ if generator is None:
97
+ return None
98
+ return Narrator(text_generator=generator)
99
+
100
+
101
+ # ---- App --------------------------------------------------------------
102
+
103
+ app = FastAPI(
104
+ title="APEX backend",
105
+ version="0.1.0",
106
+ description=(
107
+ "APEX race-engineer backend. LangGraph 6-node runtime + Stream "
108
+ "M.3 endpoints + Sarah end-to-end analyze pipeline + multipart "
109
+ "driver-upload analyze. Vinh-lane service per docs/vinh-backend-"
110
+ "plan.md Phase 5 task 5.2."
111
+ ),
112
+ )
113
+
114
+ # CORS: APEX frontend on Vercel needs to call this from the browser when
115
+ # wave-48 wire-flip is active. Allow all origins in this hackathon scope;
116
+ # narrow to the production Vercel domain once the deploy lands.
117
+ _ALLOWED_ORIGINS = os.environ.get(
118
+ "APEX_CORS_ORIGINS",
119
+ "https://apex-one-black.vercel.app,http://localhost:3000",
120
+ ).split(",")
121
+
122
+ app.add_middleware(
123
+ CORSMiddleware,
124
+ allow_origins=_ALLOWED_ORIGINS,
125
+ allow_credentials=False,
126
+ allow_methods=["GET", "POST", "OPTIONS"],
127
+ allow_headers=["Content-Type", "Authorization", "X-Apex-Client"],
128
+ )
129
+
130
+ # wave-48 OVERRIDE-steal #QB: initialize OpenTelemetry tracing +
131
+ # auto-instrument all FastAPI routes. No-op when APEX_OTEL_ENABLED is
132
+ # not "1"; spans export to console (or OTLP collector when
133
+ # OTEL_EXPORTER_OTLP_ENDPOINT is set).
134
+ _tracer = setup_observability(app)
135
+
136
+
137
+ @app.get("/healthz", response_model=HealthzResp)
138
+ def healthz() -> HealthzResp:
139
+ """Container readiness probe. Returns 200 once the singletons load."""
140
+ return HealthzResp(status="ok")
141
+
142
+
143
+ # ---- POST /api/audit-log ----------------------------------------------
144
+
145
+ @app.post("/api/audit-log")
146
+ async def post_audit_log(request: Request):
147
+ payload = await request.json()
148
+ if not isinstance(payload, dict):
149
+ raise HTTPException(status_code=400, detail="payload must be a JSON object")
150
+ try:
151
+ result = _audit_store.append(payload)
152
+ except AuditLogLineTooLarge as exc:
153
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
154
+ return {
155
+ "persisted": result.persisted,
156
+ "line_index": result.line_index,
157
+ "file_path": result.file_path,
158
+ }
159
+
160
+
161
+ # ---- POST /api/what-if-replay -----------------------------------------
162
+
163
+ @app.post("/api/what-if-replay")
164
+ async def post_what_if_replay(request: Request):
165
+ payload = await request.json()
166
+ if not isinstance(payload, dict):
167
+ raise HTTPException(status_code=400, detail="payload must be a JSON object")
168
+ baseline_fixture_id = payload.get("baseline_fixture_id")
169
+ mutation_key = payload.get("mutation_key")
170
+ if not baseline_fixture_id or not mutation_key:
171
+ raise HTTPException(
172
+ status_code=400,
173
+ detail="payload must contain baseline_fixture_id + mutation_key",
174
+ )
175
+ try:
176
+ result = run_what_if_replay(
177
+ baseline_fixture_id=baseline_fixture_id,
178
+ mutation_key=mutation_key,
179
+ )
180
+ except (UnknownFixtureError, UnknownMutationError) as exc:
181
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
182
+ return {
183
+ "mutated_fixture": result.mutated_fixture,
184
+ "replayed_violation_log": result.replayed_violation_log.to_text(),
185
+ "schema_version": result.schema_version,
186
+ "protocol_version": result.protocol_version,
187
+ }
188
+
189
+
190
+ # ---- GET /api/session-context -----------------------------------------
191
+
192
+ @app.get("/api/session-context")
193
+ def get_session_context():
194
+ resp = _session_provider.fetch()
195
+ return {
196
+ "tiles": [
197
+ {
198
+ "key": t.key,
199
+ "label": t.label,
200
+ "value": t.value,
201
+ "detail": t.detail,
202
+ "severity": t.severity,
203
+ }
204
+ for t in resp.tiles
205
+ ],
206
+ "fetched_at_iso": resp.fetched_at_iso,
207
+ }
208
+
209
+
210
+ # ---- GET /api/orchestration -------------------------------------------
211
+ #
212
+ # Wave-47 cascade-#53 close (Stephen-side audit R1): frontend
213
+ # `/api/orchestration` proxies via the wave-46 wire-flip helper and
214
+ # expects a typed `OrchestrationResponse` with nodes[].id + label +
215
+ # status + elapsed_ms. This endpoint executes the canonical Sarah
216
+ # Reynolds 5-lap fixture through the LangGraph 6-node runtime + returns
217
+ # the per-node trace in the FRONTEND shape (not the analyze-trace
218
+ # shape). Bound to the canonical fixtures shipped with the repo at
219
+ # fixtures/personas/sarah-reynolds-{telemetry.csv,coa.json}; if either
220
+ # is missing, returns 503 so the frontend wire-flip helper falls back
221
+ # to canned without retrying.
222
+
223
+
224
+ def _sarah_fixtures_or_503() -> tuple[Path, Path]:
225
+ # Repo-root fixtures dir; this file is app/backend/apex/server.py, so
226
+ # parents[3] resolves to the repo root reliably regardless of how the
227
+ # server is launched.
228
+ repo_root = Path(__file__).resolve().parents[3]
229
+ base = repo_root / "fixtures" / "personas"
230
+ telemetry = base / "sarah-reynolds-telemetry.csv"
231
+ coa = base / "sarah-reynolds-coa-stub.json"
232
+ if not telemetry.exists() or not coa.exists():
233
+ raise HTTPException(
234
+ status_code=503,
235
+ detail="sarah-reynolds canonical fixtures missing on backend",
236
+ )
237
+ return telemetry, coa
238
+
239
+
240
+ @app.get("/api/orchestration")
241
+ def get_orchestration() -> dict[str, Any]:
242
+ telemetry, coa = _sarah_fixtures_or_503()
243
+ trace = run_langgraph(
244
+ telemetry_csv=str(telemetry),
245
+ coa_json=str(coa),
246
+ debrief_path=None,
247
+ narrator=_build_live_narrator(),
248
+ )
249
+ nodes = [
250
+ {
251
+ "id": s.node,
252
+ "label": s.node.replace("_", " ").title(),
253
+ "status": s.status,
254
+ "elapsed_ms": s.duration_ms,
255
+ }
256
+ for s in trace.steps
257
+ ]
258
+ total_ms = sum(int(s.duration_ms) for s in trace.steps)
259
+ return {
260
+ "engine": "langgraph-v14-real",
261
+ "trace_id": f"sarah-langgraph-{int(total_ms)}ms",
262
+ "nodes": nodes,
263
+ "total_ms": total_ms,
264
+ "swap_point": trace.swap_point,
265
+ "compute_ms": total_ms,
266
+ }
267
+
268
+
269
+ # ---- POST /api/analyze ------------------------------------------------
270
+
271
+ @app.post("/api/analyze")
272
+ async def post_analyze(request: Request):
273
+ """End-to-end Sarah-style analyze pipeline.
274
+
275
+ Request shape (subset of frontend AnalyzeRequestPayload):
276
+ { "telemetry_csv_path": str,
277
+ "coa_json_path": str,
278
+ "debrief_path": str | null }
279
+
280
+ All paths must resolve to local files; production wires this to
281
+ multipart uploads + temp-dir extraction (out of scope for the
282
+ Phase 5 hackathon scaffold).
283
+ """
284
+ payload = await request.json()
285
+ telemetry_csv = payload.get("telemetry_csv_path")
286
+ coa_json = payload.get("coa_json_path")
287
+ debrief_path = payload.get("debrief_path")
288
+ if not telemetry_csv or not coa_json:
289
+ raise HTTPException(
290
+ status_code=400,
291
+ detail="payload requires telemetry_csv_path + coa_json_path",
292
+ )
293
+ if not Path(telemetry_csv).exists() or not Path(coa_json).exists():
294
+ raise HTTPException(status_code=404, detail="fixture file not found")
295
+ trace = run_langgraph(
296
+ telemetry_csv=telemetry_csv,
297
+ coa_json=coa_json,
298
+ debrief_path=debrief_path,
299
+ narrator=_build_live_narrator(),
300
+ )
301
+ return {
302
+ "coaching_report": coaching_report_to_dict(trace.final_report),
303
+ "trace": [
304
+ {
305
+ "node": s.node,
306
+ "status": s.status,
307
+ "duration_ms": s.duration_ms,
308
+ "detail": s.detail,
309
+ }
310
+ for s in trace.steps
311
+ ],
312
+ "swap_point": trace.swap_point,
313
+ }
314
+
315
+
316
+ # ---- POST /api/analyze-upload (wave-48 multipart fix) ----------------
317
+ #
318
+ # Driver-supplied telemetry + COA + debrief via multipart/form-data.
319
+ # Files are written to a per-request tempdir + run through the same
320
+ # LangGraph pipeline as /api/analyze, then cleaned up. Response shape
321
+ # is identical so the frontend can swap routes transparently.
322
+
323
+
324
+ def _validate_upload(
325
+ upload: UploadFile | None,
326
+ *,
327
+ field_name: str,
328
+ required: bool,
329
+ allowed_suffix: set[str],
330
+ max_bytes: int,
331
+ ) -> bytes | None:
332
+ """Reject too-large uploads + wrong file extensions before persisting.
333
+
334
+ Returns the file bytes on success or None when the field is optional + absent.
335
+ """
336
+ if upload is None:
337
+ if required:
338
+ raise HTTPException(
339
+ status_code=400,
340
+ detail=f"multipart field {field_name!r} is required",
341
+ )
342
+ return None
343
+ suffix = Path(upload.filename or "").suffix.lower()
344
+ if suffix not in allowed_suffix:
345
+ raise HTTPException(
346
+ status_code=415,
347
+ detail=f"{field_name} must be one of {sorted(allowed_suffix)}; "
348
+ f"got {suffix or '(no suffix)'}",
349
+ )
350
+ # Read full bytes; FastAPI streams under the hood + the file is
351
+ # closed by the framework when the request ends.
352
+ data = upload.file.read()
353
+ if len(data) > max_bytes:
354
+ raise HTTPException(
355
+ status_code=413,
356
+ detail=f"{field_name} exceeds {max_bytes // 1024} KiB limit",
357
+ )
358
+ return data
359
+
360
+
361
+ @app.post("/api/analyze-upload")
362
+ async def post_analyze_upload(
363
+ telemetry: UploadFile = File(..., description="CSV telemetry trace"),
364
+ coa: UploadFile = File(..., description="JSON Certificate of Adaptations"),
365
+ debrief: UploadFile | None = File(default=None, description="Markdown debrief"),
366
+ ):
367
+ """End-to-end multipart analyze pipeline.
368
+
369
+ wave-48 fix for the JSON-path-only `/api/analyze` endpoint: accepts
370
+ driver-supplied files directly via multipart/form-data. Frontend
371
+ `/api/upload-telemetry` + `/upload` page wire to this route when
372
+ `NEXT_PUBLIC_USE_REAL_BACKEND_V14=1` + the backend base URL is set.
373
+
374
+ Validates extensions + size caps BEFORE touching disk so a hostile
375
+ upload can never fill /tmp on the HF Spaces CPU instance.
376
+ """
377
+ telemetry_bytes = _validate_upload(
378
+ telemetry,
379
+ field_name="telemetry",
380
+ required=True,
381
+ allowed_suffix=_ALLOWED_TELEMETRY_SUFFIX,
382
+ max_bytes=_MAX_TELEMETRY_BYTES,
383
+ )
384
+ coa_bytes = _validate_upload(
385
+ coa,
386
+ field_name="coa",
387
+ required=True,
388
+ allowed_suffix=_ALLOWED_COA_SUFFIX,
389
+ max_bytes=_MAX_COA_BYTES,
390
+ )
391
+ debrief_bytes = _validate_upload(
392
+ debrief,
393
+ field_name="debrief",
394
+ required=False,
395
+ allowed_suffix=_ALLOWED_DEBRIEF_SUFFIX,
396
+ max_bytes=_MAX_DEBRIEF_BYTES,
397
+ )
398
+
399
+ # JSON sanity-check on the COA payload before pipeline execution so
400
+ # the 400 fires HERE instead of deep inside the parser.
401
+ try:
402
+ json.loads(coa_bytes.decode("utf-8"))
403
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
404
+ raise HTTPException(
405
+ status_code=400,
406
+ detail=f"coa payload is not valid JSON: {exc}",
407
+ ) from exc
408
+
409
+ # Per-request tempdir; cleanup in finally guarantees no leak even
410
+ # when run_langgraph() raises.
411
+ tmpdir = Path(tempfile.mkdtemp(prefix="apex-analyze-"))
412
+ try:
413
+ telemetry_path = tmpdir / "telemetry.csv"
414
+ coa_path = tmpdir / "coa.json"
415
+ debrief_path = tmpdir / "debrief.md" if debrief_bytes else None
416
+
417
+ telemetry_path.write_bytes(telemetry_bytes)
418
+ coa_path.write_bytes(coa_bytes)
419
+ if debrief_path:
420
+ debrief_path.write_bytes(debrief_bytes)
421
+
422
+ trace = run_langgraph(
423
+ telemetry_csv=telemetry_path,
424
+ coa_json=coa_path,
425
+ debrief_path=debrief_path,
426
+ narrator=_build_live_narrator(),
427
+ )
428
+ return {
429
+ "coaching_report": coaching_report_to_dict(trace.final_report),
430
+ "trace": [
431
+ {
432
+ "node": s.node,
433
+ "status": s.status,
434
+ "duration_ms": s.duration_ms,
435
+ "detail": s.detail,
436
+ }
437
+ for s in trace.steps
438
+ ],
439
+ "swap_point": trace.swap_point,
440
+ }
441
+ finally:
442
+ shutil.rmtree(tmpdir, ignore_errors=True)
443
+
444
+
445
+ # ---- GET /api/tspulse/anomaly (wave-48 Tier-2 ship; Vinh M3-V7) ------
446
+ #
447
+ # IBM TSPulse r1 polyphase anomaly detector against the canonical Sarah
448
+ # Reynolds telemetry fixture. When env `APEX_ENABLE_TSPULSE=1` is set
449
+ # the route invokes the real model via `tsfm_public`; otherwise the
450
+ # deterministic brake-pressure heuristic stub runs + the honest engine
451
+ # label "tspulse-stub" appears in the response.
452
+ #
453
+ # Response shape matches the frontend `TSPulseResponse` discriminated-
454
+ # union per `app/shared/types.ts` so the wire-flip helper drops the
455
+ # upstream body straight onto the panel state.
456
+
457
+
458
+ import time as _time
459
+
460
+ # Map TSPulse-scanned telemetry channels to frequency-band labels per
461
+ # the polyphase decomposition the frontend `TSPulseBand` union encodes.
462
+ # Adaptive-driver telemetry exhibits anomalies preferentially in these
463
+ # bands: speed = low (smooth dynamics), brake = mid (pulse-like driver
464
+ # input), steering = high (fast control response). Channels not in this
465
+ # map default to "low".
466
+ _CHANNEL_TO_BAND: Final[dict[str, str]] = {
467
+ "speed_mps": "low",
468
+ "brake_pa": "mid",
469
+ "steering_rad": "high",
470
+ "gear": "dc",
471
+ "coa_overlap_flag": "dc",
472
+ }
473
+
474
+
475
+ @app.get("/api/tspulse/anomaly")
476
+ def get_tspulse_anomaly() -> dict[str, Any]:
477
+ t_start = _time.time()
478
+ telemetry_path, _ = _sarah_fixtures_or_503()
479
+ telemetry = load_telemetry_csv(telemetry_path)
480
+ result = detect_anomaly(telemetry)
481
+ detection_ms = int((_time.time() - t_start) * 1000)
482
+ bands_set: list[str] = []
483
+ seen: set[str] = set()
484
+ for ch in result.channels_scanned:
485
+ band = _CHANNEL_TO_BAND.get(ch, "low")
486
+ if band not in seen:
487
+ seen.add(band)
488
+ bands_set.append(band)
489
+ if result.has_anomaly:
490
+ state = {
491
+ "status": "anomaly",
492
+ "window_index": result.window_index,
493
+ "score": result.score,
494
+ "threshold_p95": result.threshold,
495
+ "affected_bands": bands_set if bands_set else ["mid"],
496
+ "detection_ms": detection_ms,
497
+ }
498
+ else:
499
+ state = {
500
+ "status": "clean",
501
+ "window_index": result.window_index,
502
+ "score": result.score,
503
+ "threshold_p95": result.threshold,
504
+ "detection_ms": detection_ms,
505
+ }
506
+ return {
507
+ "engine": result.engine,
508
+ "compute_ms": detection_ms,
509
+ "state": state,
510
+ "swap_point": (
511
+ "Vinh M3-V7 -> app/backend/apex/tspulse/anomaly.py "
512
+ "(IBM Granite TimeSeries TSPulse r1 polyphase anomaly head)"
513
+ ),
514
+ }
515
+
516
+
517
+ __all__ = ["app"]
apex/shared/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """APEX shared package. Inter-layer contracts, logging, observability."""
apex/shared/contracts/__init__.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """APEX shared contracts package.
2
+
3
+ Single source of truth for inter-layer types:
4
+ shapes.py - canonical (B, 30, 14) tensor contract
5
+ projector.py - DifferentiableProjector Protocol (V1/V2/qpth/Theseus swap)
6
+ violations.py - PhysicsViolationLog + GuardianAudit + audit_id discipline
7
+ adapters.py - build_ttm_input scalar-to-per-step COA flag tiler
8
+ """
9
+
10
+ from .adapters import build_ttm_input
11
+ from .projector import DifferentiableProjector, PROTOCOL_VERSION, ProjectionResult
12
+ from .shapes import (
13
+ CHANNEL_COUNT,
14
+ CHANNEL_TIER_BINDING,
15
+ CHANNELS,
16
+ HORIZON,
17
+ SCHEMA_VERSION,
18
+ TENSOR_SHAPE,
19
+ channel_index,
20
+ )
21
+ from .violations import (
22
+ GuardianAudit,
23
+ GuardianVerdict,
24
+ PhysicsViolationLog,
25
+ VIOLATION_TYPES,
26
+ ViolationRecord,
27
+ ViolationType,
28
+ new_audit_id,
29
+ utc_now_iso,
30
+ )
31
+
32
+ __all__ = [
33
+ # shapes
34
+ "CHANNEL_COUNT",
35
+ "CHANNELS",
36
+ "CHANNEL_TIER_BINDING",
37
+ "HORIZON",
38
+ "SCHEMA_VERSION",
39
+ "TENSOR_SHAPE",
40
+ "channel_index",
41
+ # projector
42
+ "DifferentiableProjector",
43
+ "PROTOCOL_VERSION",
44
+ "ProjectionResult",
45
+ # adapters
46
+ "build_ttm_input",
47
+ # violations
48
+ "GuardianAudit",
49
+ "GuardianVerdict",
50
+ "PhysicsViolationLog",
51
+ "VIOLATION_TYPES",
52
+ "ViolationRecord",
53
+ "ViolationType",
54
+ "new_audit_id",
55
+ "utc_now_iso",
56
+ ]
apex/shared/contracts/adapters.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TTM-input adapter: the single place that lifts scalar COA metadata into
2
+ the per-step `coa_overlap_flag` channel of TENSOR_SHAPE.
3
+
4
+ Per docs/vinh-backend-plan.md Phase 1 task 1.9 + Phase 0 task 0.10:
5
+
6
+ > The broadcast adapter in `shared.contracts.build_ttm_input()` is the
7
+ > SINGLE place that tiles this scalar to the per-step simultaneity channel;
8
+ > it imports `TENSOR_SHAPE` from `shapes.py` rather than restating the
9
+ > shape literal; never duplicated in `forecast.py` or `validator.py`
10
+ > (Software Lead fix #2).
11
+
12
+ The function is torch-optional: if numpy is enough (V1 NumPy validator
13
+ path), the caller passes a numpy.ndarray and gets a numpy.ndarray back. If
14
+ torch is in scope (V2 cvxpylayers projector path), the caller passes a
15
+ torch.Tensor + gets a torch.Tensor back. Type hints stay loose so this
16
+ module never needs to import torch at module load time.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from .shapes import CHANNEL_COUNT, HORIZON, channel_index
24
+
25
+
26
+ def build_ttm_input(
27
+ telemetry: Any,
28
+ *,
29
+ simultaneity_permitted: bool,
30
+ coa_channel_name: str = "coa_overlap_flag",
31
+ ) -> Any:
32
+ """Tile the scalar COA `simultaneity_permitted` bool across the
33
+ per-step `coa_overlap_flag` channel of a telemetry tensor.
34
+
35
+ Args:
36
+ telemetry: array-like of shape (B, HORIZON, CHANNEL_COUNT). May be a
37
+ numpy.ndarray (V1 path) or torch.Tensor (V2 path). The function
38
+ dispatches on the type's `__class__.__name__` so neither numpy nor
39
+ torch must be importable at module-load time.
40
+ simultaneity_permitted: scalar bool sourced from
41
+ `apex.instruct.coa_parser.CoaParseResult.simultaneity_permitted`.
42
+ coa_channel_name: name of the channel that carries the per-step COA
43
+ flag. Defaults to the wave-30 D-016 binding (`coa_overlap_flag`).
44
+
45
+ Returns: the input tensor with the named channel overwritten by
46
+ 1.0 if `simultaneity_permitted` else 0.0, across all batch + horizon
47
+ indices. Other channels are untouched.
48
+
49
+ Raises: ValueError if the input rank or channel count disagrees with
50
+ shapes.TENSOR_SHAPE. This is the single boundary check; downstream
51
+ consumers can trust shape invariants from here on.
52
+ """
53
+ if telemetry.ndim != 3:
54
+ raise ValueError(
55
+ f"build_ttm_input expects a 3D tensor (B, {HORIZON}, {CHANNEL_COUNT}); "
56
+ f"got ndim={telemetry.ndim}."
57
+ )
58
+ if telemetry.shape[1] != HORIZON or telemetry.shape[2] != CHANNEL_COUNT:
59
+ raise ValueError(
60
+ f"build_ttm_input expects shape (B, {HORIZON}, {CHANNEL_COUNT}); "
61
+ f"got {tuple(telemetry.shape)}."
62
+ )
63
+
64
+ channel_idx = channel_index(coa_channel_name)
65
+ fill_value = 1.0 if simultaneity_permitted else 0.0
66
+
67
+ is_torch = telemetry.__class__.__module__.startswith("torch")
68
+ if is_torch:
69
+ out = telemetry.clone()
70
+ out[:, :, channel_idx] = fill_value
71
+ return out
72
+
73
+ out = telemetry.copy()
74
+ out[:, :, channel_idx] = fill_value
75
+ return out
76
+
77
+
78
+ __all__ = ["build_ttm_input"]
apex/shared/contracts/projector.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DifferentiableProjector Protocol. Exit ramp for the cvxpylayers lock.
2
+
3
+ Per council v2 chairman synthesis + Long-Term Architect (transcript v2):
4
+ D-013 hard-locks cvxpylayers as the differentiable optimization layer, but
5
+ the only fallback baked into the plan was "ship V1 NumPy as floor + paper
6
+ cites canonical QP" (paper-survival, not code-survival). If
7
+ cvxpylayers' latency turns out unacceptable on heterogeneous hardware in
8
+ 6 months, or if a future contributor wants to evaluate qpth / theseus,
9
+ there's no swap seam.
10
+
11
+ This Protocol is the seam. Any module that takes "a thing that projects a
12
+ forecast onto the physics-feasible set with differentiable backward" should
13
+ type-hint against DifferentiableProjector, not against a concrete class.
14
+ The four candidates the project might swap between:
15
+
16
+ - NumpyForwardProjector (V1, no backward; differentiable=False)
17
+ - CvxpyLayersProjector (V2, locked per D-013)
18
+ - QpthProjector (rejected by D-013 but future-portable)
19
+ - TheseusProjector (rejected by D-013 but future-portable)
20
+
21
+ Cost: ~30 minutes Day 3. Saves months of refactor if D-013 is ever revisited.
22
+ This is the single highest-ROI architectural decision flagged by the council.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Any, Final, Protocol, runtime_checkable
29
+
30
+ # Re-export shape constants so projector consumers only need to import this module.
31
+ from .shapes import CHANNEL_COUNT, HORIZON, TENSOR_SHAPE
32
+ from .violations import PhysicsViolationLog
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProjectionResult:
37
+ """Output of any DifferentiableProjector.project() call.
38
+
39
+ `corrected_tensor` preserves the input shape (B, 30, CHANNEL_COUNT) per
40
+ shapes.TENSOR_SHAPE. The tensor type is concrete to the implementation
41
+ (numpy.ndarray for V1 NumPy, torch.Tensor for V2 cvxpylayers). The
42
+ `violation_log` is the engine-agnostic PhysicsViolationLog the
43
+ Guardian audit + provenance footer consume. Frozen so a result cannot
44
+ mutate between projector and audit.
45
+ """
46
+
47
+ corrected_tensor: Any
48
+ violation_log: PhysicsViolationLog
49
+
50
+
51
+ @runtime_checkable
52
+ class DifferentiableProjector(Protocol):
53
+ """Projects a forecast tensor onto the physics-feasible set.
54
+
55
+ Implementations:
56
+ - May or may not be differentiable (set is_differentiable accordingly).
57
+ - MUST preserve the (B, 30, 14) shape end-to-end.
58
+ - MUST emit a PhysicsViolationLog (defined later in shared.contracts) per
59
+ forecast step that hit a constraint boundary.
60
+ - MUST be deterministic given the same input tensor and same constraint
61
+ parameters (vehicle mass, wheelbase, mu nominal, etc).
62
+
63
+ The Protocol is intentionally minimal. Concrete classes own their own
64
+ constraint-parameter constructor + numerical-hazard handling (Tikhonov
65
+ damping per D-014, stiff-ODE steady-state substitution per D-014).
66
+ """
67
+
68
+ is_differentiable: bool
69
+ """True if .backward() can flow through this projector (V2 cvxpylayers,
70
+ future qpth, future theseus). False for V1 NumPy floor. Consumers gate
71
+ end-to-end-backprop attempts on this flag.
72
+ """
73
+
74
+ def project(self, forecast: "Tensor") -> "ProjectionResult": # noqa: F821
75
+ """Project `forecast` of shape TENSOR_SHAPE onto the feasible set.
76
+
77
+ Returns a ProjectionResult containing (corrected_tensor, violation_log).
78
+ corrected_tensor preserves TENSOR_SHAPE. violation_log is per-step.
79
+ Implementations type-hint `forecast` with their concrete tensor type
80
+ (torch.Tensor for V2, numpy.ndarray for V1) at the implementation site;
81
+ this Protocol uses a string forward-ref to avoid forcing a torch import
82
+ on consumers who only need the type contract.
83
+ """
84
+ ...
85
+
86
+
87
+ PROTOCOL_VERSION: Final[str] = "0.1.0"
88
+ """Bumps when the Protocol signature changes (method add/remove, return type
89
+ shift). Distinct from shapes.SCHEMA_VERSION which versions the tensor channel
90
+ meanings; PROTOCOL_VERSION versions the projector API surface.
91
+ """
92
+
93
+
94
+ __all__ = [
95
+ "CHANNEL_COUNT",
96
+ "DifferentiableProjector",
97
+ "HORIZON",
98
+ "PROTOCOL_VERSION",
99
+ "ProjectionResult",
100
+ "TENSOR_SHAPE",
101
+ ]
apex/shared/contracts/shapes.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical tensor-shape contract for the APEX backend.
2
+
3
+ Single source of truth for the (B, 30, 14) wave-30 D-016 channel contract.
4
+ Every module that touches the inter-layer tensor MUST import TENSOR_SHAPE
5
+ and CHANNELS from here; never restate the shape literal inline.
6
+
7
+ Per council v2 (transcript 2026-05-22 v2): the prior plan had three different
8
+ shape statements across L93/L94/L118 of docs/vinh-backend-plan.md. This file
9
+ collapses them into one constant + one enumeration.
10
+
11
+ Channel names trace to paper/physics-ttm-methods.md L17-39. Each channel
12
+ binds to a wave-30 D-015 physics tier via the CHANNEL_TIER_BINDING map below;
13
+ the binding is the contract the SCP solver in app/backend/apex/physics/
14
+ projection.py reads to know which constraint applies to which channel.
15
+
16
+ SCHEMA_VERSION bumps when CHANNELS changes (add/remove/rename). Convergence-14
17
+ serializer tests freeze the shape, not the meaning; SCHEMA_VERSION is the
18
+ meaning-version that downstream consumers compare against.
19
+ """
20
+
21
+ from typing import Final
22
+
23
+ SCHEMA_VERSION: Final[str] = "0.1.0"
24
+
25
+ TENSOR_SHAPE: Final[tuple[None, int, int]] = (None, 30, 14)
26
+ """Wave-30 D-016 contract: (batch, horizon=30 steps, channels=14).
27
+
28
+ The leading None is the dynamic batch dimension. Horizon is 30 timesteps
29
+ at 1 Hz aggregation (wave-30 D-010 horizon expansion; was 24 pre-wave-30).
30
+ Channel count is 14 (wave-30 D-016; was 9 pre-wave-30). Migration adapter:
31
+ zero-pad channels 9-13 of legacy (B, 24, 9) inputs and extend time axis to 30.
32
+ """
33
+
34
+ HORIZON: Final[int] = 30
35
+ CHANNEL_COUNT: Final[int] = 14
36
+
37
+ CHANNELS: Final[tuple[str, ...]] = (
38
+ "throttle_pct", # 0: normalized throttle [0, 100]; tau = throttle_pct / 100 in SCP solver
39
+ "brake_pa", # 1: brake pressure [0, max_brake_pa]; b = brake_pa / max_brake_pa
40
+ "steering_rad", # 2: road-wheel steering angle, radians
41
+ "rpm", # 3: engine RPM
42
+ "lat_g", # 4: lateral acceleration (g)
43
+ "long_g", # 5: longitudinal acceleration (g)
44
+ "speed_mps", # 6: longitudinal speed, m/s
45
+ "gear", # 7: integer 0-8
46
+ "coa_overlap_flag", # 8: COA-derived simultaneity flag {0, 1}; tiled per-step from scalar
47
+ "tire_load_n", # 9: per-tire vertical-load aggregate (Tier 4 double-track adjusted)
48
+ "mu_v", # 10: per-step friction coefficient (Tier 5 thermal + Tier 7 Pacejka)
49
+ "track_pitch_rad", # 11: track-frame pitch radians (Tier 1 3D track geometry)
50
+ "track_bank_rad", # 12: track-frame bank radians (Tier 1)
51
+ "yaw_rate_rad_s", # 13: yaw rate radians/sec (Tier 8 kinematic integration)
52
+ )
53
+ assert len(CHANNELS) == CHANNEL_COUNT, "CHANNELS length must equal CHANNEL_COUNT"
54
+
55
+ CHANNEL_TIER_BINDING: Final[dict[str, int | None]] = {
56
+ "throttle_pct": None, # driver input, no physics tier
57
+ "brake_pa": None, # driver input
58
+ "steering_rad": None, # driver input
59
+ "rpm": None, # vehicle state, derived
60
+ "lat_g": 8, # Tier 8 kinematic
61
+ "long_g": 8, # Tier 8 kinematic
62
+ "speed_mps": 8, # Tier 8 kinematic
63
+ "gear": None, # vehicle state
64
+ "coa_overlap_flag": 0, # Tier 0 COA-derived constraint
65
+ "tire_load_n": 4, # Tier 4 double-track load transfer
66
+ "mu_v": 5, # Tier 5 tire thermal; consumed by Tier 7 Pacejka combined-slip
67
+ "track_pitch_rad": 1, # Tier 1 3D track geometry
68
+ "track_bank_rad": 1, # Tier 1
69
+ "yaw_rate_rad_s": 8, # Tier 8
70
+ }
71
+ assert set(CHANNEL_TIER_BINDING.keys()) == set(CHANNELS), (
72
+ "CHANNEL_TIER_BINDING must cover every channel in CHANNELS"
73
+ )
74
+
75
+
76
+ def channel_index(name: str) -> int:
77
+ """Return the channel-axis index for a named channel.
78
+
79
+ Use this in slicing rather than hard-coding integers; rename-safe.
80
+ """
81
+ return CHANNELS.index(name)
apex/shared/contracts/violations.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inter-layer data contracts: violation records, log, guardian audit.
2
+
3
+ Single source of truth for the types that flow physics-layer -> serializer
4
+ -> Guardian audit -> narrator -> provenance footer. Every module on that
5
+ chain imports from here; never redefines.
6
+
7
+ Engine-agnostic by construction (council v2 Long-Term Architect load-bearing
8
+ wall #2): the V1 NumPy validator and the V2 cvxpylayers projector both emit
9
+ `PhysicsViolationLog` instances, and `PhysicsViolationLog.to_text()` produces
10
+ byte-identical strings for the same `ViolationRecord` content regardless of
11
+ which engine produced it. This lets D-A survive a V2 cut: the paper §3.2
12
+ canonical-engine framing remains honest because V1 and V2 emit the same
13
+ violation text on the same fixture.
14
+
15
+ Convergence-14 floor (council v2 chairman + plan task 2.6b): round-trip
16
+ serializer assertion. `record.to_text()` then `ViolationRecord.from_text()`
17
+ returns an equal record; running `.to_text()` twice on the same record
18
+ produces byte-identical output. Tested in
19
+ app/backend/tests/test_serializer.py.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from typing import Final, Literal
28
+
29
+
30
+ # ---- Violation type taxonomy (Convergence-14) --------------------------
31
+ # 14 kinematic violation types per the Convergence-14 expansion (G3 floor at
32
+ # 5 types Day 4, G7-adjacent task 4.2 expands to 14). Each type binds to a
33
+ # wave-30 D-015 physics tier. Tier 0 is COA-derived; Tiers 1-8 are physics.
34
+
35
+ ViolationType = Literal[
36
+ "friction_ellipse_exceeded", # Tier 7 Pacejka combined-slip
37
+ "forward_euler_inconsistent", # Tier 8 kinematic (Δv vs long_g over Δt)
38
+ "bicycle_kinematic_break", # Tier 8 kinematic (lat_g vs steering * v)
39
+ "coa_simultaneity_violation", # Tier 0 COA-derived brake+throttle overlap
40
+ "jerk_bound_exceeded", # Tier 8 kinematic (D-011 frequency caveat)
41
+ "tire_load_negative", # Tier 4 double-track load-transfer
42
+ "tire_thermal_diverged", # Tier 5 thermal model
43
+ "yaw_rate_kinematic_break", # Tier 8 (omega vs steering * v / wheelbase)
44
+ "speed_below_pit_minimum", # Tier 8 (v_x near-zero singularity per D-014)
45
+ "track_geometry_oob", # Tier 1 3D track-frame (pitch/bank OOB)
46
+ "gear_ratio_inconsistent", # vehicle-dynamics consistency (RPM vs v_x)
47
+ "aerodynamic_load_inverted", # Tier 6 (downforce sign at v_x)
48
+ "lateral_load_transfer_oob", # Tier 4
49
+ "longitudinal_load_transfer_oob", # Tier 4
50
+ ]
51
+ """14 violation types covered by the Convergence-14 serializer suite."""
52
+
53
+ VIOLATION_TYPES: Final[tuple[str, ...]] = (
54
+ "friction_ellipse_exceeded",
55
+ "forward_euler_inconsistent",
56
+ "bicycle_kinematic_break",
57
+ "coa_simultaneity_violation",
58
+ "jerk_bound_exceeded",
59
+ "tire_load_negative",
60
+ "tire_thermal_diverged",
61
+ "yaw_rate_kinematic_break",
62
+ "speed_below_pit_minimum",
63
+ "track_geometry_oob",
64
+ "gear_ratio_inconsistent",
65
+ "aerodynamic_load_inverted",
66
+ "lateral_load_transfer_oob",
67
+ "longitudinal_load_transfer_oob",
68
+ )
69
+ assert len(VIOLATION_TYPES) == 14, "Convergence-14 must enumerate 14 types"
70
+
71
+
72
+ # ---- ViolationRecord: one row of the log --------------------------------
73
+
74
+ @dataclass(frozen=True)
75
+ class ViolationRecord:
76
+ """One violation at one forecast step.
77
+
78
+ Frozen so a ViolationRecord cannot mutate between emission and audit.
79
+ Equality is structural so round-trip serializer tests can use ==.
80
+
81
+ Field order is the serialization order; `to_text()` writes fields in
82
+ declaration order, `from_text()` parses them back in the same order.
83
+ Reordering fields here is a SCHEMA_VERSION bump per shapes.py policy.
84
+ """
85
+
86
+ step: int # forecast horizon step index (0..29)
87
+ type: ViolationType # one of VIOLATION_TYPES
88
+ severity: float # how far past the constraint boundary (>= 0)
89
+ channel_values: dict[str, float] # subset of CHANNELS at this step
90
+ tier: int # wave-30 D-015 tier this violation hit
91
+
92
+
93
+ # ---- PhysicsViolationLog: ordered list per forecast --------------------
94
+
95
+ @dataclass
96
+ class PhysicsViolationLog:
97
+ """The full per-forecast violation log emitted by validator or projector.
98
+
99
+ Engine-agnostic by design. V1 NumPy validator and V2 cvxpylayers projector
100
+ both emit this exact type. `.to_text()` is the load-bearing serializer
101
+ the Guardian BYOC audit consumes; identical input must produce
102
+ byte-identical text regardless of which engine produced the records.
103
+
104
+ Empty log == no violations == FCVR contribution of 0 for this forecast.
105
+ """
106
+
107
+ records: list[ViolationRecord] = field(default_factory=list)
108
+ forecast_step_count: int = 30 # HORIZON from shapes.py; redundant for audit
109
+ engine: Literal["v1_numpy", "v2_cvxpylayers", "v2_scp_unrolled"] = "v1_numpy"
110
+
111
+ def is_empty(self) -> bool:
112
+ return len(self.records) == 0
113
+
114
+ def fcvr(self) -> float:
115
+ """Forecast Constraint Violation Rate: fraction of horizon steps with
116
+ at least one violation. Matches the metric scp_spike.py computed at
117
+ the friction-ellipse level for the D-027 gate.
118
+ """
119
+ if self.forecast_step_count == 0:
120
+ return 0.0
121
+ violated_steps = {r.step for r in self.records}
122
+ return len(violated_steps) / self.forecast_step_count
123
+
124
+ def to_text(self) -> str:
125
+ """Deterministic, engine-agnostic serialization.
126
+
127
+ Format is line-oriented for easy diffing in golden-fixture tests:
128
+ ENGINE v1_numpy
129
+ STEPS 30
130
+ # records sorted by (step, type, tier) for byte-determinism
131
+ R step=03 type=friction_ellipse_exceeded tier=7 severity=0.1234 ch=long_g:1.310,lat_g:0.420
132
+ ...
133
+
134
+ Channel-value subsets serialize with keys sorted alphabetically;
135
+ floats use 4-decimal precision (golden-fixture stability).
136
+ """
137
+ sorted_records = sorted(
138
+ self.records, key=lambda r: (r.step, r.type, r.tier)
139
+ )
140
+ lines = [f"ENGINE {self.engine}", f"STEPS {self.forecast_step_count}"]
141
+ for r in sorted_records:
142
+ ch_text = ",".join(
143
+ f"{k}:{v:.4f}" for k, v in sorted(r.channel_values.items())
144
+ )
145
+ lines.append(
146
+ f"R step={r.step:02d} type={r.type} tier={r.tier} "
147
+ f"severity={r.severity:.4f} ch={ch_text}"
148
+ )
149
+ return "\n".join(lines) + "\n"
150
+
151
+
152
+ # ---- GuardianAudit: BYOC audit verdict + provenance --------------------
153
+ # Schema mirrors the canonical frontend contract at app/shared/types.ts L323-345
154
+ # per D-032 (frontend-backend type alignment via canonical schema mirror).
155
+ # Verdict is the discriminator; shape variants follow per-verdict per the
156
+ # frontend TypeScript discriminated union.
157
+
158
+ GuardianVerdict = Literal["approve", "flag", "reject"]
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class GuardianAudit:
163
+ """Granite Guardian 4.1 BYOC custom-rules audit verdict.
164
+
165
+ Mirrors `app/shared/types.ts` L323-345 discriminated union by
166
+ `verdict`. Three valid shapes:
167
+
168
+ approve: {verdict, reasoning_trace, audit_id}
169
+ flag: {verdict, reasoning_trace, flagged_concerns, audit_id}
170
+ reject: {verdict, reasoning_trace, blocked_recommendations, audit_id}
171
+
172
+ The variant fields default to empty tuples so callers can construct
173
+ any verdict with a single dataclass; the frontend decoder narrows
174
+ by reading `verdict` and asserting the appropriate optional field
175
+ is non-empty.
176
+
177
+ `audit_id` is set ONCE at Guardian.audit() entry via `uuid4()`,
178
+ never None per council v2 Software Lead fix #9. The provenance
179
+ footer (Phase 3 task 3.6) asserts non-None on this field; Phase 3
180
+ task 3.6b is the contract test.
181
+ """
182
+
183
+ verdict: GuardianVerdict
184
+ reasoning_trace: tuple[str, ...]
185
+ audit_id: str # uuid4 hex; never empty
186
+ flagged_concerns: tuple[str, ...] = () # populated on verdict="flag"
187
+ blocked_recommendations: tuple[str, ...] = () # populated on verdict="reject"
188
+
189
+
190
+ def new_audit_id() -> str:
191
+ """Generate a fresh audit_id at Guardian.audit() entry.
192
+
193
+ Centralized here so the next contributor cannot accidentally use a
194
+ different ID scheme; provenance footer + log lines + UI all
195
+ correlate via this single producer.
196
+ """
197
+ return uuid.uuid4().hex
198
+
199
+
200
+ def utc_now_iso() -> str:
201
+ """Audit timestamp helper.
202
+
203
+ Use ISO 8601 UTC with seconds precision so log lines sort lexicographically.
204
+ """
205
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
206
+
207
+
208
+ __all__ = [
209
+ "GuardianAudit",
210
+ "GuardianVerdict",
211
+ "PhysicsViolationLog",
212
+ "VIOLATION_TYPES",
213
+ "ViolationRecord",
214
+ "ViolationType",
215
+ "new_audit_id",
216
+ "utc_now_iso",
217
+ ]
apex/shared/logging.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured logging with audit_id correlation across all backend layers.
2
+
3
+ Council v2 SRE peer catch: no audit_id correlation across forecast/projection/
4
+ Guardian logs means post-demo debrief is archaeology. Every log line emitted
5
+ by the backend carries audit_id + commit_sha + model_versions so a single
6
+ filter `audit_id=<hex>` reconstructs the full request trace from TTM
7
+ forward through Guardian audit to provenance footer.
8
+
9
+ Format: line-oriented JSON (newline-delimited). Trivial to grep, pipe through
10
+ jq, ship to a log aggregator if APEX ever leaves the demo box.
11
+
12
+ Usage:
13
+ from apex.shared.logging import get_logger, audit_context
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ with audit_context(audit_id): # set once at Guardian.audit() entry
18
+ logger.info("forecast.completed", forecast_shape=tuple(out.shape))
19
+ logger.warning("projection.fcvr", fcvr=0.067, threshold=0.0)
20
+
21
+ # outside the context manager, audit_id falls back to "no_audit"
22
+ logger.info("startup.completed")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from contextlib import contextmanager
33
+ from contextvars import ContextVar
34
+ from datetime import datetime, timezone
35
+ from functools import lru_cache
36
+ from typing import Any, Iterator
37
+
38
+
39
+ # ---- Audit-id correlation (the council v2 SRE peer's fix) -------------
40
+
41
+ _audit_id_var: ContextVar[str] = ContextVar("audit_id", default="no_audit")
42
+
43
+
44
+ @contextmanager
45
+ def audit_context(audit_id: str) -> Iterator[None]:
46
+ """Bind `audit_id` to all log lines emitted within this block.
47
+
48
+ Guardian.audit() opens this context at entry; every downstream log line
49
+ (validator, projection, narrator, provenance) inherits the same audit_id
50
+ automatically.
51
+ """
52
+ token = _audit_id_var.set(audit_id)
53
+ try:
54
+ yield
55
+ finally:
56
+ _audit_id_var.reset(token)
57
+
58
+
59
+ def current_audit_id() -> str:
60
+ return _audit_id_var.get()
61
+
62
+
63
+ # ---- Provenance baked into every line ---------------------------------
64
+
65
+ @lru_cache(maxsize=1)
66
+ def commit_sha() -> str:
67
+ """Resolve current commit SHA once per process.
68
+
69
+ Returns 'unknown' if git is unavailable (e.g. running from a wheel or in
70
+ a container without .git). Cached so we do not exec git on every log line.
71
+ """
72
+ try:
73
+ out = subprocess.check_output(
74
+ ["git", "rev-parse", "--short", "HEAD"],
75
+ stderr=subprocess.DEVNULL,
76
+ timeout=2,
77
+ )
78
+ return out.decode().strip()
79
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
80
+ return "unknown"
81
+
82
+
83
+ @lru_cache(maxsize=1)
84
+ def model_versions() -> dict[str, str]:
85
+ """Best-effort version snapshot of the load-bearing libraries.
86
+
87
+ Cached once per process. Returns "unknown" for libraries that fail
88
+ to import (the production backend will not run in that case, but
89
+ structured logging must never crash on import-time discovery).
90
+ """
91
+ versions: dict[str, str] = {}
92
+ for name in ("torch", "transformers", "tsfm_public", "cvxpy", "cvxpylayers", "numpy"):
93
+ try:
94
+ module = __import__(name)
95
+ versions[name] = getattr(module, "__version__", "no_version_attr")
96
+ except ImportError:
97
+ versions[name] = "not_installed"
98
+ return versions
99
+
100
+
101
+ # ---- JSON line formatter ----------------------------------------------
102
+
103
+ class _AuditJSONFormatter(logging.Formatter):
104
+ """One JSON object per line.
105
+
106
+ Schema:
107
+ ts ISO 8601 UTC seconds precision
108
+ level INFO|WARNING|ERROR|...
109
+ logger qualified module name
110
+ event short snake_case event name (passed as msg)
111
+ audit_id set by audit_context, or 'no_audit'
112
+ commit_sha resolved once per process
113
+ models version snapshot dict
114
+ ... all logger.info(**kwargs) keyword args appear as top-level
115
+ """
116
+
117
+ def format(self, record: logging.LogRecord) -> str:
118
+ payload: dict[str, Any] = {
119
+ "ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
120
+ "level": record.levelname,
121
+ "logger": record.name,
122
+ "event": record.getMessage(),
123
+ "audit_id": current_audit_id(),
124
+ "commit_sha": commit_sha(),
125
+ "models": model_versions(),
126
+ }
127
+ # Surface caller-supplied kwargs (logger.info("ev", k=v)) as top-level fields.
128
+ extras = getattr(record, "extras", None)
129
+ if extras:
130
+ for k, v in extras.items():
131
+ if k not in payload:
132
+ payload[k] = v
133
+ return json.dumps(payload, default=str, sort_keys=False)
134
+
135
+
136
+ class _StructuredAdapter(logging.LoggerAdapter):
137
+ """Lets callers write logger.info("event.name", k=v, k2=v2)."""
138
+
139
+ def process(self, msg: str, kwargs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
140
+ extras = kwargs.pop("extras", {})
141
+ # Hoist any kwargs that aren't standard logging params into extras.
142
+ reserved = {"exc_info", "stack_info", "stacklevel", "extra"}
143
+ spurious = {k: kwargs.pop(k) for k in list(kwargs) if k not in reserved}
144
+ merged = {**extras, **spurious}
145
+ kwargs["extra"] = {"extras": merged}
146
+ return msg, kwargs
147
+
148
+
149
+ @lru_cache(maxsize=None)
150
+ def _root_handler_installed() -> bool:
151
+ """Install our JSON handler on the root logger exactly once.
152
+
153
+ Idempotent: multiple get_logger() calls do not stack handlers.
154
+ """
155
+ handler = logging.StreamHandler(stream=sys.stderr)
156
+ handler.setFormatter(_AuditJSONFormatter())
157
+ root = logging.getLogger("apex")
158
+ root.addHandler(handler)
159
+ root.setLevel(os.environ.get("APEX_LOG_LEVEL", "INFO"))
160
+ root.propagate = False
161
+ return True
162
+
163
+
164
+ def get_logger(name: str) -> _StructuredAdapter:
165
+ """Return a structured-JSON logger for `name` (typically __name__).
166
+
167
+ The returned adapter accepts kwargs that get serialized as top-level
168
+ JSON fields on each log line. Use snake_case event names as the message.
169
+ """
170
+ _root_handler_installed()
171
+ if not name.startswith("apex"):
172
+ name = f"apex.{name}"
173
+ return _StructuredAdapter(logging.getLogger(name), {})
174
+
175
+
176
+ __all__ = [
177
+ "audit_context",
178
+ "commit_sha",
179
+ "current_audit_id",
180
+ "get_logger",
181
+ "model_versions",
182
+ ]
apex/tspulse/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IBM TSPulse polyphase anomaly detector (Vinh M3-V7 swap-point).
2
+
3
+ Wave-48 ship. Module exposes the lazy `TSPulseAnomalyDetector`; routes
4
+ import from this package + cache the singleton at module load.
5
+ """
6
+
7
+ from apex.tspulse.anomaly import (
8
+ TSPulseAnomalyDetector,
9
+ TSPulseAnomalyResult,
10
+ detect_anomaly,
11
+ get_anomaly_detector,
12
+ )
13
+
14
+ __all__ = [
15
+ "TSPulseAnomalyDetector",
16
+ "TSPulseAnomalyResult",
17
+ "detect_anomaly",
18
+ "get_anomaly_detector",
19
+ ]
apex/tspulse/anomaly.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IBM TSPulse r1 polyphase anomaly detector (Vinh M3-V7 swap-point).
2
+
3
+ wave-48 Tier-2 ship. Closes the frontend `/api/tspulse/anomaly` canned-
4
+ fallback by wiring the real IBM Granite TimeSeries TSPulse r1 1M-param
5
+ polyphase anomaly head locally on the backend.
6
+
7
+ Model: `ibm-granite/granite-timeseries-tspulse-r1`. ~1M params; small;
8
+ CPU-friendly inference. Loaded lazily on first request via the
9
+ `tsfm_public` package's `TSPulseForReconstruction.from_pretrained()`
10
+ factory. Cached in module scope so the 200-400 ms first-load cost
11
+ amortizes across subsequent requests.
12
+
13
+ Honesty surface: trace detail reports `engine = "tspulse-r1-anomaly"`
14
+ when the real model loaded successfully + `engine = "tspulse-stub"`
15
+ when the model could not load (no env flag set OR import error).
16
+
17
+ Anomaly head per the model card: input `(B, context_len, channels)`
18
+ tensor, output reconstruction error per timestep. We compute the
19
+ window-mean reconstruction error + flag the highest-error window as
20
+ the anomaly index.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ from dataclasses import dataclass
28
+ from typing import Final, Optional
29
+
30
+ import numpy as np
31
+
32
+ from apex.shared.contracts import CHANNEL_COUNT, channel_index
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ _MODEL_ID: Final[str] = "ibm-granite/granite-timeseries-tspulse-r1"
37
+
38
+ # Sarah Reynolds telemetry channels TSPulse keys on: speed + brake +
39
+ # steering. These three carry the strongest anomaly signal for adaptive
40
+ # hand-control drivers per the Vinh-side V7 swap-point contract.
41
+ _ANOMALY_CHANNELS: Final[tuple[str, ...]] = ("speed_mps", "brake_pa", "steering_rad")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TSPulseAnomalyResult:
46
+ """Output of a single TSPulse anomaly detection pass."""
47
+
48
+ engine: str
49
+ has_anomaly: bool
50
+ window_index: int
51
+ score: float
52
+ threshold: float
53
+ channels_scanned: tuple[str, ...]
54
+ detail: str
55
+
56
+
57
+ class TSPulseAnomalyDetector:
58
+ """Lazy-loaded TSPulse r1 anomaly detector.
59
+
60
+ Construct with no args; the first `.detect()` call instantiates
61
+ `TSPulseForReconstruction` from the HF cache (~80 MB download).
62
+ Subsequent calls reuse the in-memory model.
63
+ """
64
+
65
+ def __init__(self, model_id: str = _MODEL_ID):
66
+ self._model_id = model_id
67
+ self._model = None
68
+ self._torch = None
69
+
70
+ def _ensure_loaded(self) -> bool:
71
+ if self._model is not None:
72
+ return True
73
+ try:
74
+ import torch # noqa: PLC0415
75
+ from tsfm_public import TSPulseForReconstruction # noqa: PLC0415
76
+
77
+ self._torch = torch
78
+ self._device = torch.device(
79
+ "cuda" if torch.cuda.is_available() else "cpu"
80
+ )
81
+ model = TSPulseForReconstruction.from_pretrained(
82
+ self._model_id,
83
+ num_input_channels=CHANNEL_COUNT,
84
+ )
85
+ model = model.to(self._device)
86
+ model.train(False) # inference mode (equivalent to .eval())
87
+ self._model = model
88
+ logger.info("TSPulse r1 loaded; device=%s", self._device)
89
+ return True
90
+ except Exception as exc:
91
+ logger.warning("TSPulse load failed: %s", exc)
92
+ return False
93
+
94
+ def detect(self, telemetry: np.ndarray) -> TSPulseAnomalyResult:
95
+ """Run the polyphase anomaly head on a telemetry window.
96
+
97
+ Args:
98
+ telemetry: (T, CHANNEL_COUNT) float array in shapes.CHANNELS
99
+ column order. Must have T >= 30 rows; trailing 30 rows used
100
+ as the model context.
101
+
102
+ Returns:
103
+ TSPulseAnomalyResult with engine label + anomaly flag + window
104
+ index + score + threshold + per-channel scan list.
105
+ """
106
+ if telemetry.ndim != 2 or telemetry.shape[1] != CHANNEL_COUNT:
107
+ raise ValueError(
108
+ f"detect expects (T, {CHANNEL_COUNT}) channels; "
109
+ f"got {telemetry.shape}"
110
+ )
111
+ if not self._ensure_loaded():
112
+ # Stub fallback when env-gated OFF or model load fails.
113
+ return self._stub_result(telemetry)
114
+
115
+ context_len = int(self._model.config.context_length)
116
+ if telemetry.shape[0] < context_len:
117
+ # Pad with edge-repeat to match TTM convention.
118
+ pad = np.repeat(telemetry[:1], context_len - telemetry.shape[0], axis=0)
119
+ window = np.concatenate([pad, telemetry], axis=0)
120
+ else:
121
+ window = telemetry[-context_len:]
122
+
123
+ batched = window[None, :, :].astype(np.float32)
124
+ x = self._torch.from_numpy(batched).to(self._device)
125
+ with self._torch.no_grad():
126
+ out = self._model(past_values=x)
127
+
128
+ # Reconstruction error per timestep; we collapse to per-channel
129
+ # then to per-window via L2 norm. Output tensor shape per the
130
+ # TSPulse card: (batch, context_len, channels).
131
+ recon = out.reconstruction_outputs.detach().cpu().numpy()[0]
132
+ per_step_err = np.linalg.norm(window - recon, axis=1)
133
+
134
+ # Surface the worst-error window (last 10 steps of the context;
135
+ # this corresponds to the live lap's most-recent telemetry).
136
+ recent = per_step_err[-10:]
137
+ max_idx_local = int(np.argmax(recent))
138
+ score = float(recent[max_idx_local])
139
+ threshold = float(np.percentile(per_step_err, 95))
140
+ has_anomaly = score > threshold
141
+ window_index = max(0, telemetry.shape[0] - 10 + max_idx_local)
142
+
143
+ return TSPulseAnomalyResult(
144
+ engine="tspulse-r1-anomaly",
145
+ has_anomaly=has_anomaly,
146
+ window_index=window_index,
147
+ score=round(score, 4),
148
+ threshold=round(threshold, 4),
149
+ channels_scanned=_ANOMALY_CHANNELS,
150
+ detail=(
151
+ f"recon-error {score:.4f} vs p95-threshold "
152
+ f"{threshold:.4f} on window {window_index}"
153
+ ),
154
+ )
155
+
156
+ def _stub_result(self, telemetry: np.ndarray) -> TSPulseAnomalyResult:
157
+ """Deterministic stub when the model is unavailable.
158
+
159
+ Uses brake-pressure rate-of-change as a cheap heuristic so the
160
+ stub still surfaces a believable anomaly index for the demo even
161
+ when env-flag is off. Honest engine label distinguishes the
162
+ stub from the real wire so judges + reviewers can verify which
163
+ path ran via the response.
164
+ """
165
+ brake_col = channel_index("brake_pa")
166
+ brake = telemetry[:, brake_col]
167
+ deltas = np.abs(np.diff(brake)) if brake.size > 1 else np.array([0.0])
168
+ # Last 10 deltas heuristic.
169
+ recent = deltas[-10:] if deltas.size >= 10 else deltas
170
+ max_idx_local = int(np.argmax(recent))
171
+ score = float(recent[max_idx_local])
172
+ threshold = float(np.percentile(deltas, 95)) if deltas.size > 0 else 0.0
173
+ return TSPulseAnomalyResult(
174
+ engine="tspulse-stub",
175
+ has_anomaly=score > threshold and score > 1e5,
176
+ window_index=max(0, telemetry.shape[0] - 10 + max_idx_local),
177
+ score=round(score, 4),
178
+ threshold=round(threshold, 4),
179
+ channels_scanned=_ANOMALY_CHANNELS,
180
+ detail=(
181
+ "deterministic brake-pressure rate-of-change heuristic; "
182
+ "set APEX_ENABLE_TSPULSE=1 to load the IBM Granite "
183
+ "TimeSeries TSPulse r1 polyphase anomaly head"
184
+ ),
185
+ )
186
+
187
+
188
+ # Module-level singleton + env-gated loader.
189
+ _singleton: Optional[TSPulseAnomalyDetector] = None
190
+ _load_attempted: bool = False
191
+
192
+
193
+ def get_anomaly_detector() -> Optional[TSPulseAnomalyDetector]:
194
+ """Lazy-load + return the TSPulse detector singleton.
195
+
196
+ Returns None when `APEX_ENABLE_TSPULSE` is not set; callers can
197
+ swap to the stub path in that case.
198
+ """
199
+ global _singleton, _load_attempted
200
+ if _singleton is not None:
201
+ return _singleton
202
+ if _load_attempted:
203
+ return _singleton
204
+ _load_attempted = True
205
+ if os.environ.get("APEX_ENABLE_TSPULSE", "").strip() not in {"1", "true", "yes"}:
206
+ return None
207
+ _singleton = TSPulseAnomalyDetector()
208
+ return _singleton
209
+
210
+
211
+ def detect_anomaly(telemetry: np.ndarray) -> TSPulseAnomalyResult:
212
+ """Module-level convenience wrapper.
213
+
214
+ Returns the stub result if the singleton is unavailable; otherwise
215
+ delegates to the real detector.
216
+ """
217
+ detector = get_anomaly_detector()
218
+ if detector is None:
219
+ # Build a one-shot stub-only detector so the response surface
220
+ # is consistent shape regardless of env state.
221
+ return TSPulseAnomalyDetector()._stub_result(telemetry)
222
+ return detector.detect(telemetry)
223
+
224
+
225
+ __all__ = [
226
+ "TSPulseAnomalyDetector",
227
+ "TSPulseAnomalyResult",
228
+ "detect_anomaly",
229
+ "get_anomaly_detector",
230
+ ]
apex/ttm/.gitkeep ADDED
File without changes
apex/ttm/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """APEX TTM layer: frozen Granite TimeSeries TTM r2 + channel-mix decoder head."""
apex/ttm/forecast.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen TTM-r2 zero-shot forecast wrapper (Phase 2 Day 4 task 2.8).
2
+
3
+ Three responsibilities (in dependency order):
4
+
5
+ 1. `aggregate_to_1hz`: collapse raw N Hz telemetry to the macroscopic 1 Hz
6
+ mini-sector backbone (D-011 path A; the wave-30 horizon contract is
7
+ 30 steps at 1 Hz per shapes.HORIZON). Per-channel aggregation rules
8
+ respect driver-input semantics: brake pressure and throttle pct
9
+ preserve peaks (a 20 ms brake spike must not be averaged away), the
10
+ gear channel preserves the last value of each second, everything
11
+ else uses the mean.
12
+
13
+ 2. `shape_ttm_input`: align an aggregated telemetry array to the
14
+ TTM-r2 context-window contract. Pads short telemetry by repeating
15
+ the first row (edge-pad, matching the G1 smoke convention at
16
+ `logs/day-03-g1-ttm-smoke.md`) and tail-truncates long telemetry so
17
+ the most recent context drives the prediction.
18
+
19
+ 3. `TtmForecaster`: the actual frozen-model holder. Loaded once;
20
+ `.forecast()` returns a `(B, HORIZON, CHANNEL_COUNT)` tensor. Heavy
21
+ dependency (torch + tsfm_public + 600MB HF download) so it lives in
22
+ a class that's only instantiated when a forecast is actually
23
+ needed. Unit tests cover surfaces 1 + 2; the integration test
24
+ (task 2.10) covers surface 3 end-to-end.
25
+
26
+ The engine-agnostic boundary lives downstream in `apex.physics.validator`
27
+ and `apex.shared.contracts.violations`; this module produces tensors,
28
+ not violation logs.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass, field
34
+ from typing import Final
35
+
36
+ import numpy as np
37
+
38
+ from apex.shared.contracts import CHANNEL_COUNT, CHANNELS, HORIZON, channel_index
39
+
40
+
41
+ # ---- Aggregation rules: which channels peak, which last-value, rest mean ---
42
+
43
+ _DEFAULT_PEAK_CHANNELS: Final[tuple[str, ...]] = (
44
+ "brake_pa", # driver-input peak preserves brief spikes
45
+ "throttle_pct", # driver-input peak preserves shifts
46
+ "lat_g", # acceleration peaks matter for friction-ellipse audit
47
+ "long_g",
48
+ )
49
+
50
+ _DEFAULT_LAST_CHANNELS: Final[tuple[str, ...]] = (
51
+ "gear", # discrete; averaging is nonsense
52
+ "coa_overlap_flag", # discrete {0, 1}
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AggregationConfig:
58
+ """Per-channel aggregation rule overrides.
59
+
60
+ Default rules apply when this dataclass is left at its defaults. Callers
61
+ that need a different aggregation policy (e.g. the polyphase 50 Hz path
62
+ B will pass an instance with empty peak_channels because it preserves
63
+ the original sample rate) construct a custom instance.
64
+ """
65
+
66
+ peak_channels: tuple[str, ...] = field(
67
+ default_factory=lambda: _DEFAULT_PEAK_CHANNELS
68
+ )
69
+ last_value_channels: tuple[str, ...] = field(
70
+ default_factory=lambda: _DEFAULT_LAST_CHANNELS
71
+ )
72
+
73
+
74
+ def aggregate_to_1hz(
75
+ telemetry: np.ndarray,
76
+ *,
77
+ source_hz: int,
78
+ config: AggregationConfig | None = None,
79
+ ) -> np.ndarray:
80
+ """Collapse `telemetry` from `source_hz` to 1 Hz mini-sector rows.
81
+
82
+ Args:
83
+ telemetry: (T, CHANNEL_COUNT) raw array in CHANNELS column order.
84
+ source_hz: positive integer source sample rate. `source_hz=1` is a
85
+ no-op pass-through.
86
+ config: aggregation rule overrides; defaults applied when None.
87
+
88
+ Returns: (floor(T / source_hz), CHANNEL_COUNT) float64 array. Partial
89
+ trailing windows are dropped; the wave-30 D-011 path A is anchored
90
+ on full-second mini-sectors, so a 2.4s capture yields 2 rows.
91
+ """
92
+ if source_hz <= 0:
93
+ raise ValueError(f"source_hz must be positive; got {source_hz}.")
94
+ if telemetry.ndim != 2 or telemetry.shape[1] != CHANNEL_COUNT:
95
+ raise ValueError(
96
+ f"aggregate_to_1hz expects (T, {CHANNEL_COUNT}) channels; "
97
+ f"got {telemetry.shape}."
98
+ )
99
+
100
+ cfg = config or AggregationConfig()
101
+ full_seconds = telemetry.shape[0] // source_hz
102
+ if full_seconds == 0:
103
+ return np.zeros((0, CHANNEL_COUNT), dtype=np.float64)
104
+
105
+ # Reshape into (seconds, source_hz, channels) for vectorized aggregation.
106
+ trimmed = telemetry[: full_seconds * source_hz].astype(np.float64, copy=False)
107
+ windowed = trimmed.reshape(full_seconds, source_hz, CHANNEL_COUNT)
108
+
109
+ peak_idx = {channel_index(c) for c in cfg.peak_channels if c in CHANNELS}
110
+ last_idx = {channel_index(c) for c in cfg.last_value_channels if c in CHANNELS}
111
+
112
+ out = np.empty((full_seconds, CHANNEL_COUNT), dtype=np.float64)
113
+ for ch in range(CHANNEL_COUNT):
114
+ if ch in peak_idx:
115
+ out[:, ch] = windowed[:, :, ch].max(axis=1)
116
+ elif ch in last_idx:
117
+ out[:, ch] = windowed[:, -1, ch]
118
+ else:
119
+ out[:, ch] = windowed[:, :, ch].mean(axis=1)
120
+ return out
121
+
122
+
123
+ def shape_ttm_input(
124
+ telemetry: np.ndarray,
125
+ *,
126
+ context_length: int,
127
+ dtype: np.dtype = np.float32,
128
+ ) -> np.ndarray:
129
+ """Align `telemetry` to TTM-r2's (1, context_length, CHANNEL_COUNT) input.
130
+
131
+ Pads short telemetry by repeating the first row (edge-pad, matching
132
+ the G1 smoke at `logs/day-03-g1-ttm-smoke.md`). Truncates long
133
+ telemetry from the head so the tail (most recent samples) drives
134
+ the prediction.
135
+ """
136
+ if telemetry.ndim != 2 or telemetry.shape[1] != CHANNEL_COUNT:
137
+ raise ValueError(
138
+ f"shape_ttm_input expects (T, {CHANNEL_COUNT}) channels; "
139
+ f"got {telemetry.shape}."
140
+ )
141
+
142
+ T = telemetry.shape[0]
143
+ if T < context_length:
144
+ pad = np.repeat(telemetry[:1], context_length - T, axis=0)
145
+ aligned = np.concatenate([pad, telemetry], axis=0)
146
+ else:
147
+ aligned = telemetry[-context_length:]
148
+
149
+ return aligned.astype(dtype, copy=False)[None, :, :]
150
+
151
+
152
+ # ---- TtmForecaster: heavy class, loaded lazily -------------------------
153
+
154
+ class TtmForecaster:
155
+ """Frozen Granite TimeSeries TTM-r2 zero-shot forecaster.
156
+
157
+ Loads `ibm-granite/granite-timeseries-ttm-r2` once per instance.
158
+ `.forecast(telemetry, source_hz=N)` aggregates -> shapes -> forwards
159
+ and returns a `(1, HORIZON, CHANNEL_COUNT)` numpy array matching
160
+ `shapes.TENSOR_SHAPE` (with batch=1).
161
+
162
+ This class is NOT imported at module load; callers must construct it
163
+ explicitly. The unit-test suite covers `aggregate_to_1hz` +
164
+ `shape_ttm_input` without instantiating this class; the integration
165
+ test (task 2.10) instantiates it and runs a real forward pass.
166
+ """
167
+
168
+ def __init__(self, model_id: str = "ibm-granite/granite-timeseries-ttm-r2"):
169
+ import torch
170
+ from tsfm_public import TinyTimeMixerForPrediction
171
+
172
+ self._torch = torch
173
+ self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
174
+ self._model = TinyTimeMixerForPrediction.from_pretrained(
175
+ model_id,
176
+ num_input_channels=CHANNEL_COUNT,
177
+ prediction_filter_length=HORIZON,
178
+ ).to(self._device).eval()
179
+ self._context_length: int = int(self._model.config.context_length)
180
+
181
+ @property
182
+ def context_length(self) -> int:
183
+ return self._context_length
184
+
185
+ def forecast(
186
+ self,
187
+ telemetry: np.ndarray,
188
+ *,
189
+ source_hz: int,
190
+ config: AggregationConfig | None = None,
191
+ ) -> np.ndarray:
192
+ """End-to-end zero-shot forecast: aggregate -> shape -> forward."""
193
+ aggregated = aggregate_to_1hz(telemetry, source_hz=source_hz, config=config)
194
+ shaped = shape_ttm_input(aggregated, context_length=self._context_length)
195
+ x = self._torch.from_numpy(shaped).to(self._device)
196
+ with self._torch.no_grad():
197
+ out = self._model(past_values=x)
198
+ return out.prediction_outputs.detach().cpu().numpy()
199
+
200
+
201
+ __all__ = [
202
+ "AggregationConfig",
203
+ "TtmForecaster",
204
+ "aggregate_to_1hz",
205
+ "shape_ttm_input",
206
+ ]
apex/ttm/g1_smoke.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """G1. TTM zero-shot smoke on a real FastF1 5-lap export (Phase 0 task 0.7).
2
+
3
+ G-0.5 already proved TTM-r2 loads on RTX 3060 Ti + emits (B, 30, 14) on a
4
+ random tensor. G1 strengthens that proof by running the same forward pass
5
+ on a real FastF1 5-lap telemetry slice (Bahrain 2024 Q, cached Phase 0
6
+ task 0.6), measuring load + inference latency against the council v2
7
+ budget (< 60s end-to-end per plan G1 row).
8
+
9
+ FastF1 ships a reduced channel set (no analog brake_pa, no steering_rad,
10
+ no separated G-channels per pre-mortem row 62). G1's purpose is to prove
11
+ the TTM-forward path works on real telemetry, not to claim the 14-channel
12
+ contract is satisfied by FastF1. The mapping below uses FastF1's actual
13
+ channels and fills the absent ones with zeros + a single warning at the
14
+ top of the log so downstream consumers know the gap.
15
+
16
+ Run from repo root:
17
+ app/backend/.venv/Scripts/python.exe -u app/backend/apex/ttm/g1_smoke.py
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import torch
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[4]
30
+ sys.path.insert(0, str(REPO_ROOT / "app" / "backend"))
31
+
32
+ from apex.shared.contracts import CHANNEL_COUNT, CHANNELS, HORIZON, channel_index, new_audit_id # noqa: E402
33
+ from apex.shared.logging import audit_context, get_logger # noqa: E402
34
+
35
+ logger = get_logger("ttm.g1_smoke")
36
+
37
+ # FastF1 telemetry has these analog channels available; the rest we fill with
38
+ # zeros and document in the pre-mortem (row 62 channel-availability gap).
39
+ FASTF1_CHANNEL_MAP = {
40
+ "throttle_pct": "Throttle", # 0..100
41
+ "brake_pa": "Brake", # BOOLEAN in FastF1; tile as 0/3.5e6 Pa to give the validator something to chew
42
+ "rpm": "RPM",
43
+ "speed_mps": "Speed", # FastF1 ships km/h; divide by 3.6
44
+ "gear": "nGear",
45
+ }
46
+ FASTF1_ABSENT_CHANNELS = (
47
+ "steering_rad", "lat_g", "long_g", "coa_overlap_flag",
48
+ "tire_load_n", "mu_v", "track_pitch_rad", "track_bank_rad", "yaw_rate_rad_s",
49
+ )
50
+
51
+
52
+ def load_5lap_export() -> np.ndarray:
53
+ """Pull 5 laps of Hamilton's Bahrain 2024 Q telemetry from the cache.
54
+
55
+ Returns a (T, 14) float32 array in CHANNELS column order. T is whatever
56
+ the 5-lap concatenated telemetry length is at FastF1's native sampling
57
+ rate (the cache holds raw telemetry at ~50 Hz).
58
+ """
59
+ import fastf1
60
+ fastf1.Cache.enable_cache(str(REPO_ROOT / "app" / "backend" / ".fastf1_cache"))
61
+ session = fastf1.get_session(2024, "Bahrain", "Q")
62
+ session.load(telemetry=True, laps=True, weather=False)
63
+
64
+ # Hamilton was driver '44' in 2024.
65
+ laps = session.laps.pick_drivers("44").iloc[:5]
66
+ parts = []
67
+ for lap in laps.iterlaps():
68
+ # iterlaps yields (idx, lap) tuples
69
+ idx, lap_row = lap
70
+ car_data = lap_row.get_car_data()
71
+ parts.append(car_data)
72
+ import pandas as pd
73
+ car = pd.concat(parts, ignore_index=True)
74
+
75
+ # Build (T, 14) in CHANNELS order
76
+ T = len(car)
77
+ out = np.zeros((T, CHANNEL_COUNT), dtype=np.float32)
78
+ for our_name, ff1_name in FASTF1_CHANNEL_MAP.items():
79
+ i = channel_index(our_name)
80
+ if ff1_name not in car.columns:
81
+ logger.warning("g1.fastf1_column_missing", column=ff1_name)
82
+ continue
83
+ col = car[ff1_name].to_numpy(dtype=np.float32)
84
+ if our_name == "speed_mps":
85
+ col = col / 3.6 # km/h -> m/s
86
+ if our_name == "brake_pa":
87
+ col = col.astype(np.float32) * 3.5e6 # bool -> ~3.5 MPa peak
88
+ out[:, i] = col
89
+ return out
90
+
91
+
92
+ def main() -> int:
93
+ print("=" * 72)
94
+ print("G1 - TTM zero-shot smoke on FastF1 5-lap export")
95
+ print("=" * 72)
96
+ audit_id = new_audit_id()
97
+ with audit_context(audit_id):
98
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
99
+ print(f"device: {device}; audit_id: {audit_id}")
100
+ logger.info("g1.start", device=str(device))
101
+
102
+ # ---- Load 5-lap export from FastF1 cache -------------------------
103
+ print("[1/4] loading 5-lap Bahrain 2024 Q (Hamilton) from cache ...")
104
+ t0 = time.time()
105
+ telemetry = load_5lap_export()
106
+ load_s = time.time() - t0
107
+ print(f" loaded in {load_s:.2f}s; shape={telemetry.shape} channels={CHANNEL_COUNT}")
108
+ logger.info(
109
+ "g1.fastf1_loaded",
110
+ elapsed_s=round(load_s, 2),
111
+ shape=tuple(telemetry.shape),
112
+ absent_channels=FASTF1_ABSENT_CHANNELS,
113
+ )
114
+
115
+ # ---- Build TTM input (1 sample, context_length window) -----------
116
+ print("[2/4] loading TTM-r2 ...")
117
+ from tsfm_public import TinyTimeMixerForPrediction
118
+ t0 = time.time()
119
+ model = TinyTimeMixerForPrediction.from_pretrained(
120
+ "ibm-granite/granite-timeseries-ttm-r2",
121
+ num_input_channels=CHANNEL_COUNT,
122
+ prediction_filter_length=HORIZON,
123
+ ).to(device).eval()
124
+ ttm_load_s = time.time() - t0
125
+ print(f" loaded in {ttm_load_s:.2f}s; context_length={model.config.context_length}")
126
+ logger.info("g1.ttm_loaded", elapsed_s=round(ttm_load_s, 2))
127
+
128
+ ctx = model.config.context_length
129
+ T = telemetry.shape[0]
130
+ if T < ctx:
131
+ # Edge-pad: replicate first row
132
+ print(f" telemetry T={T} < context_length={ctx}; edge-padding")
133
+ pad = np.repeat(telemetry[:1], ctx - T, axis=0)
134
+ telemetry = np.concatenate([pad, telemetry], axis=0)
135
+ x_np = telemetry[-ctx:][None, :, :] # (1, ctx, 14)
136
+ x = torch.from_numpy(x_np).to(device)
137
+ print(f" ttm input shape: {tuple(x.shape)}")
138
+
139
+ # ---- TTM forward ------------------------------------------------
140
+ print("[3/4] TTM forward (zero-shot) ...")
141
+ # Warm-up call (CUDA kernels JIT)
142
+ with torch.no_grad():
143
+ _ = model(past_values=x)
144
+ torch.cuda.synchronize() if device.type == "cuda" else None
145
+ t0 = time.time()
146
+ with torch.no_grad():
147
+ out = model(past_values=x)
148
+ torch.cuda.synchronize() if device.type == "cuda" else None
149
+ infer_ms = (time.time() - t0) * 1000
150
+ print(f" inference took {infer_ms:.1f} ms (warm)")
151
+ print(f" output shape: {tuple(out.prediction_outputs.shape)}")
152
+ logger.info("g1.ttm_forward", warm_ms=round(infer_ms, 1), output_shape=tuple(out.prediction_outputs.shape))
153
+
154
+ # ---- Verdict -----------------------------------------------------
155
+ print("[4/4] verdict ...")
156
+ expected = (1, HORIZON, CHANNEL_COUNT)
157
+ total_load_s = load_s + ttm_load_s
158
+ shape_ok = tuple(out.prediction_outputs.shape) == expected
159
+ load_ok = total_load_s < 60.0 # plan G1 row: load + 1Hz inference < 60s
160
+ infer_ok = infer_ms < 60_000 # inference itself well under 60s
161
+ all_finite = bool(torch.isfinite(out.prediction_outputs).all())
162
+ verdict = shape_ok and load_ok and infer_ok and all_finite
163
+ print(f" shape == {expected}: {shape_ok}")
164
+ print(f" load(FastF1+TTM) < 60s: {load_ok} ({total_load_s:.2f}s)")
165
+ print(f" warm inference < 60s: {infer_ok} ({infer_ms:.1f}ms)")
166
+ print(f" all-finite output: {all_finite}")
167
+ print()
168
+ print("=" * 72)
169
+ print(f"VERDICT: {'PASS' if verdict else 'FAIL'}")
170
+ print("=" * 72)
171
+ logger.info("g1.verdict", pass_=verdict, total_load_s=round(total_load_s, 2), warm_ms=round(infer_ms, 1))
172
+ return 0 if verdict else 1
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
apex/vision/.gitkeep ADDED
File without changes
fixtures/personas/sarah-reynolds-coa-stub.json ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "fictional_persona": true,
4
+ "watermark": "Sarah Reynolds is a fictional persona created by Stephen Sookra for the IBM SkillsBuild AI Builders Challenge May 2026 APEX submission. Any resemblance to real adaptive racers is coincidental. See docs/sarah-reynolds-persona.md for the full persona brief.",
5
+ "schema_version": "0.1.0",
6
+ "fixture_purpose": "Synthetic COA (Certificate of Adaptations) document consumed by Vinh's task 1.3 COA simultaneity-flag extraction pipeline (`app/backend/apex/instruct/coa_parser.py`). Mirrors the FIA Appendix L COA structure (medical-certificate + adaptive-equipment provisions) per the MME Motorsport hand-control hardware specification. APEX does not assert specific FIA Article numbers in the fixture; the appendix governs the COA framework but the per-article enumeration is owned by FIA-published documentation.",
7
+ "fixture_author": "Stephen Sookra",
8
+ "fixture_authored_iso": "2026-05-24",
9
+ "wave": "wave-42 Lane F.A close-out per docs/vinh-phase-1-handoff.md Q1 decision",
10
+ "cross_references": [
11
+ "docs/sarah-reynolds-persona.md",
12
+ "docs/vinh-phase-1-handoff.md",
13
+ "fixtures/personas/sarah-reynolds-telemetry-stub.csv",
14
+ "docs/decision-log.md D-022 (COA-parameterized brake-throttle simultaneity gate)",
15
+ "research/fia-appendix-l-2024.pdf (cited as the governing-document source; the actual FIA template is not reproduced verbatim per IP licensing caution + per-article numeric enumeration is not asserted in this fixture per the no-invented-FIA-articles project compliance rule)"
16
+ ]
17
+ },
18
+ "driver_id": "sarah-reynolds-britcar-2026",
19
+ "issuing_authority": {
20
+ "name": "Motorsport UK Medical Commission",
21
+ "country_code": "GBR",
22
+ "issuing_office": "London"
23
+ },
24
+ "certificate_metadata": {
25
+ "certificate_number": "MSUK-MED-COA-2026-0184",
26
+ "issued_iso": "2026-02-12",
27
+ "expires_iso": "2027-02-11",
28
+ "renewal_window_days": 60,
29
+ "fia_appendix_l_revision": "2024.1"
30
+ },
31
+ "driver_metadata": {
32
+ "full_name": "Sarah Reynolds",
33
+ "date_of_birth_iso": "1992-04-18",
34
+ "racing_license_number": "MSUK-RACE-2024-7821",
35
+ "license_grade": "National A",
36
+ "competition_class": "BritCar Endurance Championship, Class 4 (Production Touring)",
37
+ "preferred_team": "Limitless Racing"
38
+ },
39
+ "medical_findings": {
40
+ "primary_condition": "T6 complete spinal cord injury (2018 motorcycle collision)",
41
+ "asia_impairment_scale": "A",
42
+ "neurological_level": "T6",
43
+ "cognitive_status": "intact",
44
+ "vision_assessment": {
45
+ "left_eye_corrected": "6/6",
46
+ "right_eye_corrected": "6/6",
47
+ "binocular_field_degrees": 180,
48
+ "color_vision_normal": true
49
+ },
50
+ "cardiovascular_assessment": {
51
+ "resting_heart_rate_bpm": 62,
52
+ "vo2_max_l_per_min": 3.1,
53
+ "ecg_normal": true,
54
+ "stress_echo_normal": true
55
+ },
56
+ "musculoskeletal_assessment": {
57
+ "upper_limb_function_normal": true,
58
+ "grip_strength_left_kg": 38,
59
+ "grip_strength_right_kg": 41,
60
+ "trunk_stability_score": 4
61
+ }
62
+ },
63
+ "fia_appendix_l_conditional_approvals": [
64
+ {
65
+ "article_section": "coa_sec_hand_controls",
66
+ "fia_appendix_l_reference": "Article TBD per published revision (adaptive-equipment provisions)",
67
+ "condition": "hand_controls_required",
68
+ "approval_status": "approved",
69
+ "rationale": "Driver has no functional motor control below the T6 dermatome; hand-mounted brake and throttle controls are the sole means of pedal-input substitution."
70
+ },
71
+ {
72
+ "article_section": "coa_sec_simultaneity",
73
+ "fia_appendix_l_reference": "Article TBD per published revision (adaptive-equipment provisions)",
74
+ "condition": "simultaneity_permitted",
75
+ "approval_status": "approved",
76
+ "rationale": "MME Motorsport hand-control hardware permits simultaneous brake-and-throttle actuation via independent lever paths (brake lever + throttle ring on the steering wheel). Driver demonstrated controlled trail-brake-into-corner-apex technique during the Motorsport UK medical recertification track day at Donington Park on 2026-02-10. Simultaneity is biomechanically required for adaptive trail-braking and is medically approved.",
77
+ "evidence_log_id": "MSUK-MED-EVID-2026-0184-track-day"
78
+ },
79
+ {
80
+ "article_section": "coa_sec_egress",
81
+ "fia_appendix_l_reference": "Article TBD per published revision (emergency-egress provisions)",
82
+ "condition": "emergency_egress_demonstrated",
83
+ "approval_status": "approved",
84
+ "rationale": "Driver demonstrated 7.2-second egress from a stationary touring-car cockpit with full HANS device + adaptive-equipment quick-release. Time meets the FIA-published threshold for Class 4 competition."
85
+ },
86
+ {
87
+ "article_section": "coa_sec_thermal",
88
+ "fia_appendix_l_reference": "Article TBD per published revision (medical-condition provisions)",
89
+ "condition": "thermal_load_tolerance",
90
+ "approval_status": "approved",
91
+ "rationale": "Sweat-mediated thermoregulation is impaired below T6. Driver runs a Cool-Suit liquid-cooled garment during all sessions exceeding 30 minutes. Approved with mandatory cool-suit equipment."
92
+ }
93
+ ],
94
+ "adaptive_equipment_specifications": {
95
+ "supplier": "MME Motorsport (Marko Mlakar, Slovenia)",
96
+ "supplier_consent_per_surface": "named in this fixture per Stephen Sookra + Marko Mlakar consent grant 2026-05-22; cross-referenced in project memory project_apex_consent_mme_motorsport.md",
97
+ "hand_control_configuration": {
98
+ "brake_lever_position": "left of steering column",
99
+ "brake_lever_throw_mm": 110,
100
+ "brake_lever_force_n_max": 280,
101
+ "throttle_ring_position": "steering wheel right-side outer ring",
102
+ "throttle_ring_throw_degrees": 35,
103
+ "throttle_ring_force_n_max": 22,
104
+ "simultaneity_geometry": "independent lever paths permit any combination of brake-throttle actuation including full overlap"
105
+ },
106
+ "steering_wheel_modifications": {
107
+ "diameter_mm": 320,
108
+ "grip_type": "quick-release suede with thumb-rest contour",
109
+ "paddle_shift_left": "downshift",
110
+ "paddle_shift_right": "upshift",
111
+ "additional_buttons": ["pit_limiter", "drs_request", "radio_ptt", "thermal_safe_mode"]
112
+ },
113
+ "seat_configuration": {
114
+ "model": "MME-RecaroPole-Adaptive-2025",
115
+ "harness_points": 6,
116
+ "lateral_support_cm": 22,
117
+ "thoracic_support_cm": 18,
118
+ "cool_suit_compatible": true
119
+ }
120
+ },
121
+ "simultaneity_permission_flag": true,
122
+ "annotations_for_extraction_pipeline": {
123
+ "primary_flag_extraction_target": "simultaneity_permission_flag (boolean true) at the document root",
124
+ "tier_0_constraint_synthesis": "When simultaneity_permission_flag is true, the V1 NumPy validator + V2 cvxpylayers projector MUST suppress the standard coa_simultaneity_violation rule at the tier-0 layer per D-022. The Sarah Reynolds adaptive-equipment configuration is the canonical positive case for this gate. APEX derives the flag from the approved hardware specifications + medical-finding fields above; no specific FIA Article number is asserted by APEX (the appendix governs the COA framework but the per-article enumeration is owned by FIA-published documentation).",
125
+ "extraction_text_anchors": [
126
+ "fia_appendix_l_conditional_approvals[1] (coa_sec_simultaneity) ... condition: simultaneity_permitted ... approval_status: approved",
127
+ "hand_control_configuration.simultaneity_geometry ... independent lever paths permit any combination of brake-throttle actuation including full overlap"
128
+ ],
129
+ "negative_test_mutation": "Flipping simultaneity_permission_flag to false produces the able-bodied baseline case where the projector emits the standard violation; this is the MUTATION_COA_OVERLAP_INVERT what-if replay path per `app/frontend/lib/what-if-replay.ts`."
130
+ }
131
+ }