Skip to content

Repository files navigation

Helmsman

AI Energy Supply-Chain Resilience for Import-Dependent Economies

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.

Hormuz replay — signals, graph propagation, reroute plan, counterfactual ticker


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.


Table of contents


What it does

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.

Verify it yourself

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.

Core features

1. The 2026 Hormuz Replay — the counterfactual

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.

Counterfactual ticker — no-plan baseline vs projected

  • 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 ReplaySource with 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.

2. Adaptive Procurement Orchestrator

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.

Procurement plan modal — trace, binding constraints, graph paths

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 trace

3. Multi-hop Knowledge Graph

A 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.

4. Geopolitical Risk Intelligence Agent

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.

5. Interactive What-If — compounding shocks

Interactive what-if — blocking Hormuz, routes replan live

Toggle Restrict mode and click any node (chokepoint, corridor, port, refinery, or supplier) on the map. The orchestrator re-plans live:

  1. The supply-chain impact meter updates (lost bpd, threshold exceeded).
  2. Old routes through blocked nodes disappear; new dotted routes appear via surviving corridors. Utilized rings shift to surviving suppliers.
  3. Block a refinery too — the plan re-solves across the whole surviving network, compounding shocks, not a single-point replan.
  4. 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.

6. Live Signal Ingestion

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

Live/injected signal flowing through the pipeline

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.

7. Copilot — the easy way in

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.

8. Supply-Chain Digital Twin

A React + TypeScript + MapLibre GL geospatial twin renders the full India–Gulf network: chokepoints, corridors, suppliers, ports, refineries, demand regions.

Digital twin — map, hovers, event stream, sidebar

  • 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.


Why you can trust these numbers

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:

  • SignalExtractor caches every extracted signal by raw_signal_id (in-memory per run + persistent NewsRepository across 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.py replays captured LLM responses so uv run pytest is offline and deterministic without an API key.

Architecture

                          ┌─────────────────────────────────────────────┐
                          │              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 Clock is a dependency: processors never call now() directly; they read time from an injected Clock (wall-clock live, simulated in replay). This is the small discipline that makes "same code path" actually true.

Repository layout

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.


Tech stack

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

Run

Docker (one command)

docker compose up --build

If your Docker doesn't have the compose subcommand, 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 + ports
Local (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 + twin

Then 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 --build

Optional model overrides:

export HELMSMAN_STRONG_MODEL=z-ai/glm-5.2                 # signal extraction
export HELMSMAN_FAST_MODEL=deepseek/deepseek-v4-flash    # RAG synthesis

The 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.


API reference

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)

Tests

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 network

Configurability

Everything 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 THRESHOLD in assumptions.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.


Beyond crude oil

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 chokepoint for pipeline, refinery for terminal, SupplierOption.grade for BTU grade. The pipeline, the graph, and the orchestrator are unchanged.
  • Semiconductorschokepointfab region, corridorshipping lane, gradenode-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 in assumptions.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.


Honest limitations

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.yaml assumes 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_URL is 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 MarketDataProvider port has a StaticMarketData adapter; 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.

About

An agentic platform that detects geopolitical & logistics disruption from live signals and produces executable crude-procurement reroutes — proven by replaying a full Strait of Hormuz closure through the exact system that would run live.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages