An agentic platform that detects geopolitical & logistics disruption from live signals and produces executable crude-procurement reroutes — proven against a real-world backtest of a full Strait of Hormuz closure, the 2026 crisis.
$611M direct cost avoided · 17 days faster recovery · $22.81/bbl premium saved — cited, reproducible, and computed by replaying the 2026 full-closure scenario through the identical system that would run live.
TL;DR — India imports 88% of its crude; 40% transits Hormuz; the reserve is 9.5 days. The 2026 Strait of Hormuz crisis was a full closure — triggered by Operation Epic Fury (US+Israel strikes, 28 Feb 2026), it collapsed tanker traffic from 138 to 2 ships/day, pushed Brent from ~$80 to ~$120 (peak ~$126), and saw Aramco set a record $19.50/bbl Asian premium over a 47-day US naval blockade of Iran. Helmsman is an agent that ingests live disruption signals, traces them through a knowledge graph to the refineries at risk, and re-solves a constraint-feasible procurement reroute — then proves it by replaying that scenario through the same system and measuring the savings (plan-vs-no- plan, apples-to-apples against the full-closure baseline). One architecture decision makes all of this possible: the system is a deterministic, replayable event pipeline whose data source is a pluggable port — so live and historical run the identical code path, and the backtest is free and trustworthy.
- What it does
- Verify it yourself
- Core features
- Why you can trust these numbers
- Architecture
- Repository layout
- Tech stack
- Run
- API reference
- Tests
- Configurability
- Beyond crude oil
- Honest limitations
A disruption signal (a headline, a tanker reroute, a sanctions notice) flows through one pipeline:
SignalSource (port)
├── LiveSource → polls news / AIS / sanctions / prices (wall-clock time)
└── ReplaySource → emits historical events with past timestamps (sim clock)
│
▼ RawSignal events (identical schema, identical code path)
[ Extraction ] → [ Knowledge Graph ] → [ Risk Scoring ] → [ Scenario Impact ]
│
threshold breach
▼
[ Procurement Orchestrator ]
▼
ranked, constraint-feasible, executable reroute plan
Because LiveSource and ReplaySource both emit the same RawSignal events
through the same processors (time is read from an injected Clock, never
now()), the 2026 Hormuz backtest runs the identical code path as live — so
the counterfactual is not a separate model, it is the same system on historical
input.
The output is an executable plan object, not a recommendation in prose: ranked procurement orders with supplier, corridor, volume, ETA, cost delta, binding constraints, and the graph paths + signals that justify it — the thing a procurement desk could act on in hours.
The entire backtest — the $611M headline, the 17-day recovery, the reroute plan — is reproducible in one command, with no LLM and no network:
uv sync --extra dev
uv run pytest # deterministic test suite (graph, optimizer, risk, scenario, replay)test_hormuz_replay_produces_plan runs the curated 2026 Hormuz timeline
through the pipeline and asserts the counterfactual improvement.
test_replay_is_deterministic runs it twice and asserts identical output.
test_paced_replay_matches_nonpaced guarantees the demo path (paced) and the
test path (non-paced) produce the same plan. No LLM nondeterminism, no hidden
state — the rigor is the point.
The clincher: run the same live system over the 2026 crisis timeline and show what Helmsman would have done before the historical spot-premium spike.
-
A curated dataset of real 2026 events (Operation Epic Fury, the IRGC closure declaration, the tanker-traffic collapse, the Brent surge, the Aramco record premium) feeds
ReplaySourcewith past timestamps. -
Signals fire on a paced timeline (~30s); the graph propagation lights up (amber dashed rings on impacted nodes); the orchestrator emits a ranked reroute with dotted teal-green route lines before the historical price spike.
-
The counterfactual panel compares the plan's projected outcome against the no-plan baseline of the same scenario. The numbers:
No-plan (full closure) Under our plan Delta Spot premium $82.76/bbl $59.95/bbl $22.81/bbl saved Days to stabilize 47 30 17 days faster Direct cost avoided — — $611M The direct cost avoided is the conservative, directly-attributable figure:
premium_saved × at-risk_volume × risk_window_days— it counts only the volume our system reroutes (570k bpd). The broader market-exposure figure ($8.27B) is context, not a claim. See Why you can trust these numbers.
The centerpiece — an inspectable 6-step state machine triggered on a risk-threshold breach:
assess → source → enrich → score → allocate → explain
| Step | What it does |
|---|---|
| assess | Read current risk state + at-risk refineries/grades from the graph |
| source | Query alternative suppliers/routes compatible with the at-risk refinery grades |
| enrich | Pull spot price, tanker availability, port congestion via MarketDataProvider |
| score | Weighted multi-criteria score per option (price, lead time, risk exposure, grade fit) |
| allocate | OR-Tools solves the final allocation: meet the supply gap subject to tanker/port/refinery-capacity constraints, minimizing the weighted objective |
| explain | Emit a plan: ranked orders, volumes, ETAs, cost delta, binding constraints, and the signals + graph paths that justify it |
The allocate step is greedy allocation for the cheapest, fastest,
available, grade-matched supply — OR-Tools (GLOP LP solver) solves a real
constraint-feasible allocation under tanker/port/refinery constraints. The
objective is the scoring weights, so the result stays explainable — the binding
constraints table shows which limits bit. If OR-Tools isn't installed, an
explainable greedy LP-equivalent stands in behind the same Optimizer port and
emits the same binding constraints. Everything else stays boring on purpose.
Reroute = alternative sourcing, not the same cargoes on a longer route. Hormuz has no maritime bypass; "reroute via Cape/Red Sea" means sourcing from suppliers whose corridors are Cape/Red Sea (West Africa, the Americas, Russia-via-Red-Sea) to replace the Hormuz-transiting suppliers. The graph severs Hormuz for sourcing and the orchestrator pulls alternatives from surviving corridors. The Cape lead time (30d) is the voyage time for the first alternative-source cargo to arrive, which caps the with-plan recovery window.
The allocate step is the one deliberate piece of algorithmic weight: OR-Tools
(GLOP LP solver) solves a real constraint-feasible allocation. The objective is
the scoring weights, so the result stays explainable — the binding constraints
table shows which limits bit. If OR-Tools isn't installed, an explainable
greedy LP-equivalent (fills cheapest-weighted first, honors per-corridor tanker
caps) stands in behind the same Optimizer port and emits the same binding
constraints. Everything else stays boring on purpose.
The output is a typed ProcurementPlan (Pydantic), not prose:
class ProcurementPlan(BaseModel):
id: str
trigger_signal_id: str
supply_gap_bpd: float
baseline_cost_per_day: float
plan_cost_per_day: float
spot_premium_avoided_usd_bbl: float
days_to_stabilize: float
orders: list[ProcurementOrder] # supplier, corridor, grade, volume, price, eta
binding_constraints: list[BindingConstraint] # name, limit, used, slack
graph_paths: list[GraphPath] # the evidence trail
rationale: str
trace: list[OrchestratorStep] # the 6-step state-machine traceA RiskEvent on a chokepoint propagates outward through a real multi-hop
traversal — not a join table:
Hormuz → routes → suppliers → cargoes in transit → refineries dependent on that grade → demand regions
The graph is modeled as nodes + edges and traversed with a recursive DFS that prunes blocked nodes (corridors whose chokepoint is blocked, refineries whose ports are all blocked). The amber impacted rings on the twin are the literal output of that traversal.
Nodes: Supplier, Corridor, Chokepoint (Hormuz, Bab-el-Mandeb, Suez),
Route, Tanker/Cargo, Port, Refinery (with crude-grade compatibility),
DemandRegion.
Edges: supplies, transits, passes_through, compatible_with,
feeds, discharges, serves — each carrying weights (volume share, lead
time, capacity, grade fit).
Seed data — real, cited facts vs explicit assumptions
The graph is seeded from two files, kept deliberately separate:
datasets/india_crude_network.yaml— REAL, cited network facts only (suppliers, refineries, chokepoints, ports, volumes). Every value traces to a checkable source URL in the file. Volume source: Energy Institute Statistical Review of World Energy 2025 (via Visual Capitalist); share source: SBI Ecowrap (via CNBC-TV18).assumptions.yaml— EXPLICIT MODEL ASSUMPTIONS (grade compatibility, lead times, tanker availability, port→refinery routing, demand shares), every one cited and unit-tested, never presented as real data.
This separation is the honesty discipline: real data is real, assumptions are assumptions, and a judge can poke any parameter.
Graph reachability — physical delivery constraints
A supplier's volume counts as deliverable only if there is an unblocked path from its corridor (and the corridor's chokepoints) through an open port to a non-blocked refinery that accepts its grade. This is the single source of truth for restricted supply — no per-node formulas that could double-count:
_can_deliver(corridor, refinery):
corridor --discharges--> open port --serves--> non-blocked refinery (grade match)
A blocked chokepoint severs every corridor that transits it. A blocked port severs refineries served only by it. Blocking a refinery removes it from sourcing. Compound restrictions are handled by reachability re-computation — no brittle case enumeration.
A per-corridor / per-supplier / per-chokepoint disruption-probability score, updated on every signal — continuously, not weekly:
score = 0.5 * recency + 0.3 * severity + 0.2 * corroboration- Recency decays exponentially (half-life 14 days) — a fresh signal weighs more than a stale one.
- Severity is the max signal probability (LLM-extracted or keyword-scored).
- Corroboration caps at 1.0 when ≥3 independent signals agree.
All weights are explicit and unit-tested (tests/test_risk.py). A breach above
THRESHOLD = 0.55 triggers the orchestrator. Each RiskScore carries its
components, rationale, provenance (replay/manual/live), and the raw
signal ids that contributed — so a single injected signal can be surgically
removed and only the risks it actually raised are dropped.
Toggle Restrict mode and click any node (chokepoint, corridor, port, refinery, or supplier) on the map. The orchestrator re-plans live:
- The supply-chain impact meter updates (lost bpd, threshold exceeded).
- Old routes through blocked nodes disappear; new dotted routes appear via surviving corridors. Utilized rings shift to surviving suppliers.
- Block a refinery too — the plan re-solves across the whole surviving network, compounding shocks, not a single-point replan.
- The counterfactual ticker updates to reflect the new plan's projected outcome. Unrestrict and the network restores — every change flows through the identical code path.
No other entry replans under operator-driven compounding shocks in real time. This converts "we replayed history" into "we can steer the future."
How the restriction→replan flow works
POST /restrict toggles a node's blocked state. If the cumulative lost
throughput (supply + refining) exceeds 5% of baseline, the orchestrator's
react_to_restrictions() gathers every reachable surviving refinery and sources
alternatives across the whole surviving network — it does not assume a single
chokepoint target. Each restriction-driven plan is appended as a new history
entry so successive reroutes can be compared. The explain_restrictions()
graph method generates structured insights ("2.2k bpd supply cut — chokepoint
Hormuz blocks corridor hormuz", "0 bpd supply lost — 3 suppliers rerouted via
port_mundra") that surface in the impact panel.
Not replay-only. A live ingestion agent polls real feeds and flows each item through the identical pipeline:
Google News RSS → AI relevance agent (LLM scores relevance) → scrape_article tool (Bright Data) → RawSignal → bus → extractor → graph → risk → orchestrator
The agent's agentic loop: the LLM is the orchestrator, scrape_article (Bright
Data) is its only tool. It decides per item whether the headline+snippet is
relevant enough to warrant fetching the full article. When no LLM is
configured, a keyword-based relevance scorer stands in so the live trigger
still works deterministically. A background scheduler polls every
HELMSMAN_LIVE_POLL_SECONDS (default 300); /ingest triggers a manual poll.
The POST /inject endpoint lets an operator push a custom headline (or URL —
scraped to text) through the same inject() → bus → extractor → graph → risk → orchestrator path. Live or replay, it is one code path.
Interact with the whole supply-chain twin in plain language. Steer the twin, play with scenarios, and generate plans without touching the UI — the copilot drives the same controls a human operator would (block nodes, inject signals, set the crisis window, run the replay) and presents the answers in plain language. Everything it does you can also do by hand; it just does it for you, automatically.
It is an LLM function-calling agent over the same tools the REST API exposes
(inject_signal, restrict_node, get_counterfactual, run_hormuz_replay,
etc.). It parses free text — "War at the Cape Corridor, closed for 15 days" →
duration_days=15 — threads it through extraction → orchestrator →
counterfactual, and cites the dollar figure straight from the counterfactual.
A React + TypeScript + MapLibre GL geospatial twin renders the full India–Gulf network: chokepoints, corridors, suppliers, ports, refineries, demand regions.
- Live WebSocket event stream (
/ws) pushes every bus event to the twin in real time — replay (paced) and live ingest both stream as they happen. - Impacted nodes get amber dashed rings; utilized alternative suppliers get teal-green rings; reroute orders are dotted teal-green lines labeled with volume in kbpd; blocked nodes get red rings.
- Sidebar: risk scores by corridor/chokepoint (Section I), procurement plan card (Section II), counterfactual ticker (Section III).
- Scenario panel: restrict mode toggle, inject news tab, live ingest status, news feed browser.
- Plan modal: the 6-step trace, binding constraints table, graph paths.
No Mapbox token friction, no 3D globe bloat — open and fast.
The counterfactual is the single most defensible artifact, and the biggest credibility risk if hand-waved. So:
Counterfactual methodology (collapsed for brevity)
The 2026 Strait of Hormuz crisis was a full closure. Triggered by Operation Epic Fury (US+Israel strikes on Iran, 28 Feb 2026) and the assassination of Ali Khamenei, the IRGC closed the strait: tanker traffic collapsed from ~138 ships/day to 2 (JMIC, 6 Mar 2026), Brent rose from ~$80 to ~$120 (peak ~$126) over 2–9 Mar 2026, Aramco set a record $19.50/bbl Asian premium (Reuters, 6 Apr 2026), and the US imposed a 47-day naval blockade of Iran (13 Apr–29 May 2026). Our simulation models exactly this scenario — a full Hormuz closure for the operator-set crisis window (default 47d, the measured 2026 blockade duration). The cited 2026 actuals are shown as a historical reference annotation on the counterfactual panel, not used in the savings arithmetic.
The arithmetic is plan-vs-no-plan for the same scenario. The parametric scenario model is run twice on the same lost volume:
- no-plan: full lost volume, no rerouting → the full gap hits the documented elasticity curve.
- with-plan: residual gap after rerouting, lead = max order lead time.
Cited 2026 Hormuz actuals (shown as a reference, not in the arithmetic):
- $19.50/bbl spot premium — Aramco's record Asian crude premium (Reuters, 6 Apr 2026) as the closure rattled markets.
- 47 days to stabilize — the duration of the US naval blockade of Iran (13 Apr–29 May 2026; Wikipedia 2026 Strait of Hormuz crisis). Used as the default no-plan recovery window.
- Brent baseline ~$80/bbl (IEEFA/The Hindu, Mar 2026); ~50% spike ($80→$120) over the first week of the closure.
Direct cost avoided ($611M):
premium_saved ($/bbl) × at-risk_volume (bpd) × risk_window_days
It counts only the volume our system reroutes (570k bpd) — the rerouted
portion is the volume whose spot premium our plan eliminates; the unmet residual
earns nothing. The broader market exposure ($8.27B) is the market-wide
Brent premium applied to all Indian crude imports over the window — an
upper-bound context number, not a claim of full savings.
Every parameter lives in assumptions.yaml with a cited source. The parametric
chain is a small auditable function, not a neural net:
supply_gap_bpd = lost_corridor_volume − reroutable_volume(lead_time, tanker_availability)
spot_premium($/bbl) = f(supply_gap_bpd) # documented elasticity curve
refinery_run_rate = g(grade_compatibility of available substitutes)
spr_drawdown_bpd = min(supply_gap_bpd, spr_capacity_per_day)
days_of_cover = spr_volume / max(spr_drawdown_bpd, ε)
Honest accounting guards (regression-tested): an empty plan (no rerouting
possible) reports zero savings — gated by rerouted > 0 so a plan that reroutes
nothing can never print a fake win. The at-risk volume is the rerouted volume,
not the full lost gap. Unknown grades/corridors fail loud instead of returning a
plausible-looking default price. The operator-set duration_days drives the
recovery window (a 10-day window with a 30-day Cape lead yields 0 days saved —
you can't recover faster than the disruption lasts).
The replay is LLM-based and still reproducible
The replay/backtest path runs the same OpenRouterLLMClient (z-ai/glm-5.2
for extraction, deepseek-v4-flash for RAG synthesis) as a live run —
identical code path, no rules-based stand-in. Reproducibility comes from a
two-layer cache, not from disabling the LLM:
SignalExtractorcaches every extracted signal byraw_signal_id(in-memory per run + persistentNewsRepositoryacross runs), so a signal is extracted once and reused forever. The backtest never re-hits the LLM.- The counterfactual numbers come from a deterministic solver and orchestrator, never from LLM output, so the $611M figure can't be perturbed by model nondeterminism.
tests/conftest.pyreplays captured LLM responses souv run pytestis offline and deterministic without an API key.
┌─────────────────────────────────────────────┐
│ SignalSource (port) │
│ ┌───────────────┐ ┌──────────────────┐ │
│ │ LiveSource │ │ ReplaySource │ │
│ │ (news/AIS/RSS)│ │ (historical YAML)│ │
│ └───────┬───────┘ └─────────┬────────┘ │
└──────────┼──────────────────────┼───────────┘
│ RawSignal (identical) │
▼ ▼
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ ┌──────────────┐
│ Ingestion │──▶│ Intelligence │──▶│ Graph │──▶│ Risk │
│ (normalize) │ │ (LLM extract + RAG) │ │ (multi-hop) │ │ (weighted) │
└─────────────┘ └─────────────────────┘ └─────────────┘ └──────┬───────┘
│ threshold
▼ breach
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ ┌──────────────┐
│ Scenario │◀──│ Orchestrator │◀──│ MarketData │ │ API │
│ (parametric)│ │ (6-step state machine)│ │ (spot/tanker│ │ (FastAPI+WS)│
└─────────────┘ └─────────────────────┘ └─────────────┘ └──────────────┘
Ports & adapters (hexagonal): domain logic depends on interfaces, never on
infra. Bounded contexts (ingestion, intelligence, graph, risk,
scenario, orchestrator, api) are clean seams that split into services
unchanged.
Bounded contexts (each = a future service)
| Context | Responsibility |
|---|---|
| ingestion | Source adapters, normalization to RawSignal. LiveSource (Google News RSS + AI agent) and ReplaySource (curated YAML). |
| intelligence | LLM signal extraction (RawSignal → DisruptionSignal) + RAG over a curated corpus. Two-layer cache for determinism. |
| graph | Knowledge graph (supplier ⇄ corridor ⇄ chokepoint ⇄ route ⇄ port ⇄ refinery ⇄ demand); recursive multi-hop risk propagation. |
| risk | Per-corridor / per-supplier / per-chokepoint disruption probability scoring (weighted, explainable). |
| scenario | Parametric cascading-impact model (supply gap → price → run-rate → SPR). Explicit, cited, unit-tested. |
| orchestrator | The agent: assess → source → enrich → score → allocate (OR-Tools) → explain. Emits executable plan objects. |
| api | FastAPI; REST + WebSocket live event stream to the twin. |
Ports (interfaces the domain depends on)
SignalSource, Clock, EventBus, EventStore, GraphRepository,
VectorStore, LLMClient, MarketDataProvider, Optimizer, NewsRepository,
RiskScorer, ScenarioModel, Orchestrator. Each has a concrete adapter;
domain code imports ports only.
Event sourcing & idempotency
- Event-driven, stateless processors; state lives in the graph + event store.
- Idempotent handlers keyed by event id; the event log is the source of truth.
- The
Clockis a dependency: processors never callnow()directly; they read time from an injectedClock(wall-clock live, simulated in replay). This is the small discipline that makes "same code path" actually true.
helmsman/
pyproject.toml
docker-compose.yml
assumptions.yaml # versioned scenario parameters (cited)
datasets/
india_crude_network.yaml # REAL cited network facts (suppliers, refineries, ports)
hormuz_2026.yaml # curated 2026 Hormuz crisis timeline (ReplaySource input)
src/helmsman/
domain/ # pure models + ports (no infra imports)
models.py # Pydantic: RawSignal, DisruptionSignal, RiskScore, Plan...
ports.py # SignalSource, EventBus, GraphRepository, LLMClient, Clock...
contexts/
ingestion/ # { sources/{live,replay}, live_feed.py, normalize }
intelligence/ # { extraction.py, rag.py }
graph/ # (via adapters/graph.py — recursive traversal)
risk/ # { scoring.py }
scenario/ # { model.py }
orchestrator/ # { 6-step state machine, allocate via Optimizer port }
adapters/ # postgres, pgvector, redis, openrouter, optimizer, static
app/ # FastAPI wiring, WebSocket, DI composition root, counterfactual
tests/ # captured LLM responses + tests
web/ # React + TS + MapLibre twin + timeline scrubber
Dependency rule: domain imports nothing from adapters. contexts import
domain ports. app is the only place adapters are wired to ports (composition
root). This rule is what keeps the monolith splittable.
| Concern | Choice | Why |
|---|---|---|
| Language | Python 3.12 | AI/agents/data gravity; one language end-to-end |
| API | FastAPI + Pydantic | Typed contracts = the module boundary; async; WebSocket for live |
| Persistence | One Postgres = event store + relational + pgvector (RAG) + graph via recursive CTEs | One ops surface doing four jobs cleanly |
| Event bus | In-process async queue (default), Redis Streams adapter behind the same EventBus port |
In-process is more deterministic for replay; Redis is the documented scale-out swap |
| Orchestration | Hand-rolled inspectable state machine (LangGraph is the documented production swap behind the same Orchestrator port) |
The centerpiece deserves a real, inspectable state machine; no agent-framework bloat |
| Allocation | OR-Tools (GLOP LP) when installed; explainable greedy LP-equivalent fallback | Constraint-feasible allocation = genuine executability, still explainable |
| LLM | OpenRouter HTTP API (no SDK), JSON-mode structured outputs | No self-hosting; structured = reliable extraction |
| Frontend | React + TypeScript + MapLibre GL | Open (no token gates), great for the geospatial twin |
| Deploy | Docker Compose (Postgres + app + web) | One command to run the whole thing |
One controlled complexity budget, spent deliberately: the OR-Tools allocation step. That is the only place real algorithmic weight is added, because it directly buys "executable alternatives." Everything else stays boring on purpose.
What we explicitly rejected (and why)
| Rejected | Why |
|---|---|
| Neo4j | A second DB to run for a "graph" that recursive CTEs / in-memory traversal cover |
| Separate vector DB | pgvector suffices for the RAG corpus |
| Kafka (now) | Operational bloat for a one-month build; nothing needs it yet. In-process bus → Redis Streams → Kafka is the documented adapter-only swap |
| Agent-framework-everything | Turning plain services into "agents" is bloat; the orchestrator is the only thing that deserves a state machine |
| Pure black-box optimizer | Unexplainable. Pure weights look naive, not "executable." OR-Tools + weighted objective + binding constraints is the middle path |
| Mapbox | Token friction; MapLibre is open and sufficient |
| 3D globe | Bloat; 2D map is clearer for a supply-chain twin |
| Self-hosted LLM | Time sink; structured outputs from a hosted provider are reliable |
docker compose up --buildIf your Docker doesn't have the
composesubcommand, use the standalone CLI instead:docker-compose up --build. Both work identically.
| Service | Port | Purpose |
|---|---|---|
| app | localhost:31457 |
FastAPI backend + API (serves the built twin at /) |
| web | localhost:19081 |
Vite dev server with hot-reload (for frontend iteration) |
| postgres | localhost:18723 |
Postgres 16 — event store + graph + pgvector |
Open http://localhost:31457 for the full app, or
http://localhost:19081 for the live-reloading frontend during development.
Useful Docker commands
docker compose up --build # start everything (builds app image)
docker compose up -d --build # detached (background)
docker compose down # stop + remove containers (keeps pgdata volume)
docker compose down -v # stop + wipe the postgres volume (fresh state)
docker compose logs -f app # tail backend logs
docker compose logs -f web # tail frontend logs
docker compose restart app # restart just the API
docker compose exec app uv run pytest # run tests inside the container
docker compose ps # service status + portsLocal (without Docker)
uv sync --extra dev # install deps
uv run pytest # run the deterministic test suite
uv run uvicorn helmsman.app.api:app --reload # start API + twinThen POST /replay to run the 2026 Hormuz replay through the identical
pipeline, and GET /plans / GET /counterfactual to inspect the result.
With live LLM
The app calls OpenRouter for signal extraction and RAG synthesis:
export OPENROUTER_API_KEY=...
docker compose up --buildOptional model overrides:
export HELMSMAN_STRONG_MODEL=z-ai/glm-5.2 # signal extraction
export HELMSMAN_FAST_MODEL=deepseek/deepseek-v4-flash # RAG synthesisThe replay uses the same OpenRouter client; reproducibility comes from the
SignalExtractor two-layer cache (in-memory by signal id + persistent
NewsRepository), so a signal is extracted once and reused. Tests replay
captured LLM responses (tests/conftest.py) and need no API key.
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/replay |
Run the curated 2026 Hormuz replay (paced, ~30s) |
GET |
/plans |
Inspect generated procurement plans |
GET |
/counterfactual |
The $/bbl + days-saved + national-scale $ impact |
POST |
/inject |
Inject a custom news headline through the same pipeline |
POST |
/restrict |
Toggle a node restriction; the plan re-solves live |
POST |
/restrict/clear |
Clear all restrictions; drop restriction artefacts |
GET |
/restrict |
Current blocked nodes + supply-chain impact |
GET |
/graph |
Full graph (nodes, edges, blocked) |
GET |
/risk_scores |
Current risk scores (acknowledged ones hidden) |
POST |
/acknowledge |
Dismiss a risk score from the UI |
GET |
/disruptions |
Extracted disruption signals |
GET |
/events |
Full bus event log (event sourcing) |
POST |
/reset |
Clear all replay/live artefacts (preserves seed graph) |
POST |
/clear |
Selective reset by source (replay/manual/live/restrict/all) |
POST |
/ingest |
Manually trigger one live news poll |
GET |
/news |
Stored news items by dataset tag |
GET |
/news_feed |
Paginated, newest-first feed (live + replay combined) |
GET |
/injections |
List injected signals (for per-item clear) |
DELETE |
/injections/{id} |
Surgically remove one injection + its downstream artefacts |
DELETE |
/plans/{id} |
Remove a single plan from history |
GET |
/assumptions |
The versioned scenario parameters |
POST |
/rag |
RAG query over the curated corpus |
GET |
/status |
Live-ingest scheduler status |
GET |
/health |
Health check |
WS |
/ws |
WebSocket live event stream (replay + live ingestion) |
A deterministic suite (no LLM, no network) covering every core path:
| Test file | What it pins |
|---|---|
test_replay_e2e.py |
The Hormuz replay produces a plan with a counterfactual improvement; replay is deterministic (two runs, identical output); paced == non-paced; injection removal is surgical |
test_graph.py |
Multi-hop propagation reaches at-risk refineries; blocked nodes are pruned |
test_optimizer.py |
Greedy fills cheapest-first; tanker constraints are respected (OR-Tools + greedy) |
test_risk.py |
Score increases with recency & severity; corroboration boosts; threshold is sane |
test_scenario.py |
Reroutable share & spot premium are monotonic; rerouting reduces the supply gap; SPR drawdown is capped |
test_situation_and_null.py |
Counterfactual integrity guards: empty plans report zero savings; operator-set duration_days drives the recovery window (10d window + 30d Cape lead → 0 days saved); injected duration threads through extraction → orchestrator → plan; Bab-el-Mandeb full block reroutes via Cape; negative-premium regression |
test_openrouter.py |
OpenRouter adapter parses structured JSON outputs correctly |
uv run pytest # ~38 tests, deterministic, no networkEverything is configuration. assumptions.yaml holds every parameter — the
elasticity curve, risk-scoring weights, corridor lead times, refinery grades,
tanker availability, SPR volume — each with a cited source. Risk thresholds and
risk identification extend via yaml edits + prompt additions, with no domain
code change:
- New risk threshold: change
THRESHOLDinassumptions.yaml— the orchestrator triggers on the new bar. - New risk signal / entity type: extend the extraction prompt (the live LLM system prompt is the only edit). The same pipeline then recognizes and routes the new signal.
- New corridor / chokepoint / grade slate: add the nodes + edges to
datasets/india_crude_network.yaml; the graph traversal, risk scoring, and orchestrator pick them up unchanged.
The same code runs for a different region, a different commodity, or a sharper risk appetite — the config surface is the only edit.
The architecture is deliberately commodity-agnostic. The three core
abstractions — pluggable SignalSource, a typed knowledge graph with recursive
propagation, and an orchestrator that emits executable plans with binding
constraints — are not specific to crude oil:
- LNG / natural gas — swap
chokepointforpipeline,refineryforterminal,SupplierOption.gradeforBTU grade. The pipeline, the graph, and the orchestrator are unchanged. - Semiconductors —
chokepoint→fab region,corridor→shipping lane,grade→node-process compatibility. Same state machine, same constraint allocation. - Fertilizer / grains / any import-dependent commodity — the parametric
scenario model (
supply_gap → premium → run-rate → buffer drawdown) is a generic supply-shock chain; only the elasticity curve and buffer parameters change (and those are cited, versioned, swappable inassumptions.yaml).
The domain models are deliberately generic (Supplier, Corridor,
Chokepoint, Refinery, DemandRegion) rather than crude-specific. Today
this is a crude-oil resilience platform; tomorrow it is a supply-chain
resilience platform — zero domain code change.
What this is not (collapsed — read if probing)
- Scenario model is parametric, not predictive. It is a transparent auditable function, not a neural net. The point is defensible assumptions a judge can poke and a test can pin — not predictive perfection. The elasticity curve is a piecewise-linear approximation of the IEA/EIA documented relationship.
- Refinery crude slates are assumed, not real. Real per-refinery crude
source mixes are not public;
assumptions.yamlassumes a single representative grade (medium-sour) per refinery. This is explicit and cited, not hidden. - Three thin pillars are integration points, not depth. The Disruption
Scenario Modeller is the parametric chain in
assumptions.yaml; the Strategic Reserve Optimisation is the SPR drawdown term in the scenario model; the Digital Twin is the map. Each is shallow by design so the two core pillars (orchestrator + risk intelligence) have real depth. - Live ingestion is a 20-second garnish, not the hero. The replay is the hero and is fully offline/deterministic; live is the flaky segment reduced to a single toast. If the live feed is down on demo day, the inject button proves the same point safely.
- The in-memory adapters are the default. Postgres (event store + graph +
pgvector) is wired behind the same ports when
DATABASE_URLis set (docker-compose does this automatically). The in-memory adapters keep tests and the offline demo fully deterministic with no infra. - No real AIS / price feeds wired by default. The
MarketDataProviderport has aStaticMarketDataadapter; a real adapter is a straightforward port implementation. The architecture is ready; the data plumbing is out of scope for the build window.
One-line pitch: Helmsman turns energy-supply crises from reactive scramble into anticipatory, executable response — and proves it by replaying a full Hormuz closure through the exact system that would run live.
Same pipeline, live or historical.





