feat(supply-chain): Sprint 1 — Route Explorer wrapper RPC (#2980)

* feat(supply-chain): Sprint 1 — Route Explorer wrapper RPC

Adds an internal wrapper around the vendor-only route-intelligence
compute so the upcoming Route Explorer UI can call it from a browser
PRO session instead of forcing an X-WorldMonitor-Key API gate.

Backend:
- New proto get-route-explorer-lane.proto with GetRouteExplorerLane{Request,Response}
- New handler server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts
- New static lookup tables _route-explorer-static-tables.ts:
  TRANSIT_DAYS_BY_ROUTE_ID, FREIGHT_USD_BY_CARGO_TYPE,
  BYPASS_CORRIDOR_GEOMETRY_BY_ID — covers all 5 land-bridge corridors
  plus every sea-alternative corridor with hand-curated coordinates
- Wired into supply-chain handler.ts service dispatcher
- Cache key ROUTE_EXPLORER_LANE_KEY in cache-keys.ts (NOT in BOOTSTRAP_KEYS)
- Gateway entry: PREMIUM_RPC_PATHS + RPC_CACHE_TIER 'slow-browser'
- Premium path entry in src/shared/premium-paths.ts so browser PRO auth attaches

Response contract enriches route-intelligence with:
- primaryRouteGeometry polyline from TRADE_ROUTES (lon/lat pairs)
- fromPort/toPort coords on every bypass option so the client can call
  MapContainer.setBypassRoutes directly without geometry lookups
- status: 'active' | 'proposed' | 'unavailable' derived from corridor notes
  to honestly label kra_canal_future and black_sea_western_ports
- estTransitDaysRange + estFreightUsdPerTeuRange from static tables
- noModeledLane: true when origin/destination clusters share no routes

Client wrapper fetchRouteExplorerLane added to src/services/supply-chain/index.ts.

Tests: tests/route-explorer-lane.test.mts — 30-query smoke matrix
(10 country pairs × 3 HS2 codes), structural assertions only, no
hard-coded transit/cost values. Test exposes a pure computeLane()
function with an injectable status map so it does not need Redis.

Gap report (from smoke run): 12 of 30 queries fall back to a synthetic
primaryRouteId because the destination's port cluster has no shared route
with the origin (US-JP, ZA-IN, CL-CN, TR-DE × 3 HS2 each). These pairs
return noModeledLane:true; Sprint 3 will render an empty-state for them.

Plan: docs/plans/2026-04-11-001-feat-worldwide-route-explorer-plan.md

* fix(route-explorer): address PR #2980 review findings

P1: bypass warRiskTier was hard-coded to WAR_RISK_TIER_NORMAL, dropping
the live risk signal from chokepoint status. Now derived from the
statusMap via the corridor's primaryChokepointId.

P2: freight fallback in emptyResponse and client-side empty payload used
a cargo-agnostic container range for all cargo types. Removed the ranges
entirely from fallback/noModeledLane responses; they are only present
when the lane is actually modeled.

Suggestion: when noModeledLane is true, the response now returns empty
primaryRouteId, empty geometry, empty exposures, empty bypasses, and
omits transit/freight ranges. Previously it returned plausible-looking
synthetic data from the origin's first route which could mislead the UI.

Tests updated to assert the noModeledLane contract: empty fields when
the flag is set, non-empty ranges only when the lane is modeled.

* fix(route-explorer): cargo-aware route ranking + bypass waypoint risk

P1: primary route selection was order-dependent, picking whichever
shared route the origin cluster listed first. Mixed clusters like
CN/JP could return an energy lane for a container request. Now ranks
shared routes by cargo-category compatibility (container→container,
tanker→energy, bulk→bulk, roro→container) before selecting.

P1: bypass warRiskTier was copied from the primary chokepoint instead
of derived from the corridor's own waypointChokepointIds. This
overstated risk for alternatives like Cape of Good Hope whose waypoints
may have a lower risk tier. Now uses max-tier across waypoint
chokepoints, matching get-bypass-options.ts logic.

Suggestion: placeholder corridors with addedTransitDays=0 (like
gibraltar_no_bypass, cape_of_good_hope_is_bypass) are now filtered out.
Previously they could surface as active alternatives.

Regression tests added:
- CN→JP tanker: asserts energy route is selected over container route
- CN→DE with faked Suez=CRITICAL / Cape=NORMAL: asserts Cape bypass
  shows NORMAL, not CRITICAL
- ES→EG: asserts zero-transit-day placeholders are excluded

* fix(route-explorer): scope exposures to primary route + narrow placeholder filter

P1: chokepointExposures and bypassOptions were computed from the full
sharedRoutes set, mixing data from energy/container corridors into a
single response. Now scoped to the cargo-ranked primaryRouteId only,
matching the proto contract that exposures are "on the primary route."

P2: the addedTransitDays === 0 filter was too broad and removed
kra_canal_future (a proposed bypass with real modeling). Narrowed to an
explicit PLACEHOLDER_CORRIDOR_IDS set (gibraltar_no_bypass,
cape_of_good_hope_is_bypass) so proposed zero-day corridors survive and
are surfaced with CORRIDOR_STATUS_PROPOSED.

Regression tests:
- chokepointExposures follow primaryRouteId (CN->JP container)
- kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes
- placeholder filter still excludes explicit placeholders

* fix(route-explorer): address PR #2980 review comments

1. Unavailable corridors without waypoints (e.g. black_sea_western_ports)
   now derive WAR_RISK_TIER_WAR_ZONE from their CORRIDOR_STATUS_UNAVAILABLE
   status, instead of returning WAR_RISK_TIER_UNSPECIFIED. Corridors with
   waypointChokepointIds still use max-tier across those waypoints.

2. Added fixture test with non-empty status map (suez=75/HIGH,
   malacca=30/ELEVATED) so disruptionScore and warRiskTier assertions are
   not trivially satisfied by the empty-map default path.

3. Documented the single-chokepoint bypass design gap in the test gap report:
   bypassOptions only cover the primary chokepoint; multi-chokepoint routes
   show exposure for all but bypass guidance for only the top one. Sprint 3
   will decide whether to expand to top-N or add a UI hint.
This commit is contained in:
Elie Habib
2026-04-12 08:16:02 +04:00
committed by GitHub
parent 19d67cea94
commit 822eef0fa6
14 changed files with 1394 additions and 1 deletions

File diff suppressed because one or more lines are too long

View File

@@ -269,6 +269,60 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/supply-chain/v1/get-route-explorer-lane:
get:
tags:
- SupplyChainService
summary: GetRouteExplorerLane
description: |-
GetRouteExplorerLane returns the primary maritime route, chokepoint exposures,
bypass options with geometry, war risk, and static transit/freight estimates for
a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor
endpoint's compute with browser-callable auth and adds fields needed by the
Route Explorer UI.
operationId: GetRouteExplorerLane
parameters:
- name: fromIso2
in: query
required: false
schema:
type: string
- name: toIso2
in: query
required: false
schema:
type: string
- name: hs2
in: query
description: HS2 chapter code, e.g. "27", "85"
required: false
schema:
type: string
- name: cargoType
in: query
description: 'One of: container, tanker, bulk, roro'
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetRouteExplorerLaneResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -883,3 +937,131 @@ components:
description: Whether at least one viable bypass corridor exists for the primary chokepoint.
fetchedAt:
type: string
GetRouteExplorerLaneRequest:
type: object
properties:
fromIso2:
type: string
pattern: ^[A-Z]{2}$
toIso2:
type: string
pattern: ^[A-Z]{2}$
hs2:
type: string
description: HS2 chapter code, e.g. "27", "85"
cargoType:
type: string
description: 'One of: container, tanker, bulk, roro'
required:
- fromIso2
- toIso2
- hs2
- cargoType
GetRouteExplorerLaneResponse:
type: object
properties:
fromIso2:
type: string
toIso2:
type: string
hs2:
type: string
cargoType:
type: string
primaryRouteId:
type: string
description: Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane.
primaryRouteGeometry:
type: array
items:
$ref: '#/components/schemas/GeoPoint'
chokepointExposures:
type: array
items:
$ref: '#/components/schemas/ChokepointExposureSummary'
bypassOptions:
type: array
items:
$ref: '#/components/schemas/BypassCorridorOption'
warRiskTier:
type: string
disruptionScore:
type: number
format: double
estTransitDaysRange:
$ref: '#/components/schemas/NumberRange'
estFreightUsdPerTeuRange:
$ref: '#/components/schemas/NumberRange'
noModeledLane:
type: boolean
description: |-
True when the wrapper fell back to the origin's first route (no shared route
between origin and destination clusters). Signals "no modeled lane" to the UI.
fetchedAt:
type: string
GeoPoint:
type: object
properties:
lon:
type: number
format: double
lat:
type: number
format: double
description: GeoPoint is a [longitude, latitude] pair.
ChokepointExposureSummary:
type: object
properties:
chokepointId:
type: string
chokepointName:
type: string
exposurePct:
type: integer
format: int32
BypassCorridorOption:
type: object
properties:
id:
type: string
name:
type: string
type:
type: string
addedTransitDays:
type: integer
format: int32
addedCostMultiplier:
type: number
format: double
warRiskTier:
type: string
status:
type: string
enum:
- CORRIDOR_STATUS_UNSPECIFIED
- CORRIDOR_STATUS_ACTIVE
- CORRIDOR_STATUS_PROPOSED
- CORRIDOR_STATUS_UNAVAILABLE
description: |-
Status of a bypass corridor for UI labeling. "active" means usable today;
"proposed" means documented but not yet built/operational; "unavailable"
means blockaded or otherwise blocked from use.
fromPort:
$ref: '#/components/schemas/GeoPoint'
toPort:
$ref: '#/components/schemas/GeoPoint'
description: |-
BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI.
Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes
directly without any client-side geometry lookup.
NumberRange:
type: object
properties:
min:
type: integer
format: int32
max:
type: integer
format: int32
description: Inclusive integer range for transit days / freight USD estimates.

View File

@@ -0,0 +1,99 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "buf/validate/validate.proto";
import "sebuf/http/annotations.proto";
// Status of a bypass corridor for UI labeling. "active" means usable today;
// "proposed" means documented but not yet built/operational; "unavailable"
// means blockaded or otherwise blocked from use.
enum CorridorStatus {
CORRIDOR_STATUS_UNSPECIFIED = 0;
CORRIDOR_STATUS_ACTIVE = 1;
CORRIDOR_STATUS_PROPOSED = 2;
CORRIDOR_STATUS_UNAVAILABLE = 3;
}
// GeoPoint is a [longitude, latitude] pair.
message GeoPoint {
double lon = 1;
double lat = 2;
}
// BypassCorridorOption is a single enriched bypass corridor for the Route Explorer UI.
// Includes coordinate endpoints so the client can call MapContainer.setBypassRoutes
// directly without any client-side geometry lookup.
message BypassCorridorOption {
string id = 1;
string name = 2;
string type = 3;
int32 added_transit_days = 4;
double added_cost_multiplier = 5;
string war_risk_tier = 6;
CorridorStatus status = 7;
GeoPoint from_port = 8;
GeoPoint to_port = 9;
}
message ChokepointExposureSummary {
string chokepoint_id = 1;
string chokepoint_name = 2;
int32 exposure_pct = 3;
}
// Inclusive integer range for transit days / freight USD estimates.
message NumberRange {
int32 min = 1;
int32 max = 2;
}
message GetRouteExplorerLaneRequest {
string from_iso2 = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.len = 2,
(buf.validate.field).string.pattern = "^[A-Z]{2}$",
(sebuf.http.query) = {name: "fromIso2"}
];
string to_iso2 = 2 [
(buf.validate.field).required = true,
(buf.validate.field).string.len = 2,
(buf.validate.field).string.pattern = "^[A-Z]{2}$",
(sebuf.http.query) = {name: "toIso2"}
];
// HS2 chapter code, e.g. "27", "85"
string hs2 = 3 [
(buf.validate.field).required = true,
(sebuf.http.query) = {name: "hs2"}
];
// One of: container, tanker, bulk, roro
string cargo_type = 4 [
(buf.validate.field).required = true,
(sebuf.http.query) = {name: "cargoType"}
];
}
message GetRouteExplorerLaneResponse {
string from_iso2 = 1;
string to_iso2 = 2;
string hs2 = 3;
string cargo_type = 4;
// Primary trade route ID from TRADE_ROUTES config. Empty when no modeled lane.
string primary_route_id = 5;
// Polyline waypoints for the primary route (lon/lat), used for map rendering.
repeated GeoPoint primary_route_geometry = 6;
// Chokepoints on the primary route, ranked by exposure descending.
repeated ChokepointExposureSummary chokepoint_exposures = 7;
// Ranked bypass options with geometry endpoints for map rendering.
repeated BypassCorridorOption bypass_options = 8;
string war_risk_tier = 9;
double disruption_score = 10;
// Static transit estimate from a hand-curated table keyed by primary_route_id.
NumberRange est_transit_days_range = 11;
// Static freight estimate (USD per TEU or equivalent) from a hand-curated table
// keyed by cargo_type. Not a live rate quote.
NumberRange est_freight_usd_per_teu_range = 12;
// True when the wrapper fell back to the origin's first route (no shared route
// between origin and destination clusters). Signals "no modeled lane" to the UI.
bool no_modeled_lane = 13;
string fetched_at = 14;
}

View File

@@ -11,6 +11,7 @@ import "worldmonitor/supply_chain/v1/get_country_chokepoint_index.proto";
import "worldmonitor/supply_chain/v1/get_bypass_options.proto";
import "worldmonitor/supply_chain/v1/get_country_cost_shock.proto";
import "worldmonitor/supply_chain/v1/get_sector_dependency.proto";
import "worldmonitor/supply_chain/v1/get_route_explorer_lane.proto";
service SupplyChainService {
option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"};
@@ -51,4 +52,13 @@ service SupplyChainService {
rpc GetSectorDependency(GetSectorDependencyRequest) returns (GetSectorDependencyResponse) {
option (sebuf.http.config) = {path: "/get-sector-dependency", method: HTTP_METHOD_GET};
}
// GetRouteExplorerLane returns the primary maritime route, chokepoint exposures,
// bypass options with geometry, war risk, and static transit/freight estimates for
// a country pair + HS2 + cargo type. PRO-gated. Wraps the route-intelligence vendor
// endpoint's compute with browser-callable auth and adds fields needed by the
// Route Explorer UI.
rpc GetRouteExplorerLane(GetRouteExplorerLaneRequest) returns (GetRouteExplorerLaneResponse) {
option (sebuf.http.config) = {path: "/get-route-explorer-lane", method: HTTP_METHOD_GET};
}
}

View File

@@ -86,6 +86,17 @@ export const COST_SHOCK_KEY = (iso2: string, chokepointId: string) =>
export const SECTOR_DEPENDENCY_KEY = (iso2: string, hs2: string) =>
`supply-chain:sector-dep:${iso2}:${hs2}:v1` as const;
/**
* Route Explorer lane cache — per (fromIso2, toIso2, hs2, cargoType).
* NOT in bootstrap — request-varying, PRO-gated.
*/
export const ROUTE_EXPLORER_LANE_KEY = (
fromIso2: string,
toIso2: string,
hs2: string,
cargoType: string,
) => `supply-chain:route-explorer-lane:${fromIso2}:${toIso2}:${hs2}:${cargoType}:v1` as const;
/**
* Shared chokepoint status cache key — written by get-chokepoint-status, read by bypass-options and cost-shock handlers.
*/

View File

@@ -214,6 +214,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/supply-chain/v1/get-bypass-options': 'slow-browser',
'/api/supply-chain/v1/get-country-cost-shock': 'slow-browser',
'/api/supply-chain/v1/get-sector-dependency': 'slow-browser',
'/api/supply-chain/v1/get-route-explorer-lane': 'slow-browser',
'/api/health/v1/list-disease-outbreaks': 'slow',
'/api/health/v1/list-air-quality-alerts': 'fast',
'/api/intelligence/v1/get-social-velocity': 'fast',

View File

@@ -0,0 +1,217 @@
/**
* Static lookup tables for the Route Explorer wrapper RPC.
*
* These are hand-curated estimates, NOT live rate quotes. They exist because:
* - `route-intelligence` does not return transit days or freight estimates
* - `BYPASS_CORRIDORS_BY_CHOKEPOINT` has no geometry fields; the client-side
* `MapContainer.setBypassRoutes` API wants coordinate pairs, not IDs
*
* Every number here should be treated as a rough industry average, not
* authoritative. If these ever need to move, replace with a live data source
* (Baltic Exchange, Freightos, etc.) rather than extending these tables.
*/
import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry';
// ─── Transit days per TRADE_ROUTES ID ────────────────────────────────────────
/**
* Minimum and maximum transit-day estimates per trade route, keyed by the
* `id` field from `src/config/trade-routes.ts`. Ranges span different vessel
* classes and seasonal routing choices.
*/
export const TRANSIT_DAYS_BY_ROUTE_ID: Record<string, readonly [number, number]> = {
'china-europe-suez': [28, 35],
'china-us-west': [14, 18],
'china-us-east-suez': [30, 38],
'china-us-east-panama': [24, 30],
'gulf-europe-oil': [18, 25],
'gulf-asia-oil': [16, 22],
'qatar-europe-lng': [18, 24],
'qatar-asia-lng': [12, 18],
'us-europe-lng': [10, 14],
'russia-med-oil': [8, 14],
'intra-asia-container': [3, 10],
'singapore-med': [16, 22],
'brazil-china-bulk': [35, 45],
'gulf-americas-cape': [30, 42],
'asia-europe-cape': [40, 52],
'india-europe': [18, 26],
'india-se-asia': [6, 12],
'china-africa': [22, 32],
'cpec-route': [10, 16],
'panama-transit': [1, 2],
'transatlantic': [8, 14],
};
/**
* Fallback range when a `primaryRouteId` is not present in the lookup above.
* Chosen to look obviously "estimated" so UI reviewers notice if the table
* drifts out of sync with `TRADE_ROUTES`.
*/
export const TRANSIT_DAYS_FALLBACK: readonly [number, number] = [14, 28];
// ─── Freight estimate per cargo type ─────────────────────────────────────────
/**
* Very rough freight cost estimate per cargo type. For containers this is USD
* per TEU; for tankers it's USD per ton; for bulk and roro it's USD per ton
* or per unit. The units are not homogeneous — the UI labels them as "est.
* freight range" without claiming a specific unit, and users are expected to
* treat it as an order-of-magnitude indicator only.
*/
export const FREIGHT_USD_BY_CARGO_TYPE: Record<string, readonly [number, number]> = {
container: [1800, 3200],
tanker: [25, 65],
bulk: [12, 30],
roro: [900, 1800],
};
export const FREIGHT_USD_FALLBACK: readonly [number, number] = [1800, 3200];
// ─── Bypass corridor geometry ────────────────────────────────────────────────
/**
* Coordinate-pair endpoints for every bypass corridor ID in
* `BYPASS_CORRIDORS_BY_CHOKEPOINT`. The client feeds these directly to
* `MapContainer.setBypassRoutes([{fromPort, toPort}])`, which draws an arc
* between the two points.
*
* These are *representative* endpoints, not precise port coordinates. Sea
* bypass corridors generally use the source chokepoint (from the
* `CHOKEPOINT_REGISTRY`) as `fromPort` and a notional "exit" point on the
* other side of the alternative route as `toPort`. Land-bridge corridors use
* hand-curated rail/road endpoints based on the corridor's `notes` field.
*/
export const BYPASS_CORRIDOR_GEOMETRY_BY_ID: Record<
string,
{ fromPort: readonly [number, number]; toPort: readonly [number, number] }
> = {
// ── Sea alternatives (use CHOKEPOINT_REGISTRY for endpoints) ───────────
suez_cape_of_good_hope: {
fromPort: [32.3, 30.5], // Suez
toPort: [18.49, -34.36], // Cape of Good Hope
},
sumed_pipeline: {
fromPort: [32.58, 29.95], // Ain Sukhna terminal, Gulf of Suez
toPort: [28.88, 31.33], // Sidi Kerir terminal, Mediterranean
},
hormuz_cape_of_good_hope: {
fromPort: [56.5, 26.5], // Hormuz Strait
toPort: [18.49, -34.36], // Cape of Good Hope
},
btc_pipeline: {
fromPort: [49.85, 40.4], // Baku
toPort: [35.24, 36.87], // Ceyhan, Turkey
},
lombok_strait_bypass: {
fromPort: [101.5, 2.5], // Malacca Strait
toPort: [115.7, -8.5], // Lombok Strait
},
sunda_strait: {
fromPort: [101.5, 2.5], // Malacca Strait
toPort: [105.8, -6.0], // Sunda Strait
},
kra_canal_future: {
fromPort: [101.5, 2.5], // Malacca Strait
toPort: [99.3, 10.0], // Kra Isthmus (notional)
},
bab_el_mandeb_cape_of_good_hope: {
fromPort: [43.3, 12.5], // Bab el-Mandeb
toPort: [18.49, -34.36], // Cape of Good Hope
},
btc_pipeline_black_sea: {
fromPort: [49.85, 40.4], // Baku
toPort: [41.65, 41.65], // Batumi
},
panama_cape_horn: {
fromPort: [-79.7, 9.1], // Panama
toPort: [-67.3, -55.98], // Cape Horn
},
bashi_channel: {
fromPort: [119.5, 24.0], // Taiwan Strait
toPort: [121.5, 21.9], // Bashi Channel
},
miyako_strait: {
fromPort: [129.0, 34.0], // Korea Strait
toPort: [125.3, 24.85], // Miyako Strait
},
north_sea_scotland: {
fromPort: [1.5, 51.0], // Dover Strait
toPort: [-4.0, 58.5], // North-of-Scotland route
},
channel_tunnel: {
fromPort: [1.5, 51.0], // Dover Strait
toPort: [1.85, 50.92], // Eurotunnel Coquelles
},
gibraltar_no_bypass: {
fromPort: [-5.6, 35.9], // Gibraltar (degenerate "no bypass" placeholder)
toPort: [-5.6, 35.9],
},
cape_of_good_hope_is_bypass: {
fromPort: [18.49, -34.36], // Cape of Good Hope
toPort: [18.49, -34.36],
},
la_perouse_strait: {
fromPort: [129.0, 34.0], // Korea Strait
toPort: [142.0, 45.7], // La Perouse Strait
},
tsugaru_strait: {
fromPort: [129.0, 34.0], // Korea Strait
toPort: [140.7, 41.5], // Tsugaru Strait
},
black_sea_western_ports: {
fromPort: [36.6, 45.3], // Kerch Strait
toPort: [28.65, 44.18], // Constanta
},
sunda_strait_for_lombok: {
fromPort: [115.7, -8.5], // Lombok Strait
toPort: [105.8, -6.0], // Sunda Strait
},
ombai_strait: {
fromPort: [115.7, -8.5], // Lombok Strait
toPort: [124.5, -8.4], // Ombai Strait
},
// ── Land-bridge corridors (hand-curated rail/road endpoints) ──────────
aqaba_land_bridge: {
fromPort: [56.5, 26.5], // Hormuz Strait (origin side)
toPort: [35.0, 29.53], // Aqaba, Jordan
},
djibouti_rail: {
fromPort: [43.15, 11.6], // Djibouti port
toPort: [38.74, 9.03], // Addis Ababa
},
baku_tbilisi_batumi_rail: {
fromPort: [49.85, 40.4], // Baku
toPort: [41.65, 41.65], // Batumi
},
us_rail_landbridge: {
fromPort: [-118.25, 33.74], // Port of Los Angeles
toPort: [-74.15, 40.67], // Port of New York/New Jersey
},
ukraine_rail_reroute: {
fromPort: [30.74, 46.48], // Odesa
toPort: [21.0, 52.23], // Warsaw (notional EU entry)
},
};
/**
* Deterministic fallback when a corridor ID has no explicit geometry entry.
* Uses the chokepoint registry coordinate for both endpoints, which renders
* as a degenerate zero-length arc — intentionally obvious to reviewers.
*/
export function getCorridorGeometryOrFallback(
corridorId: string,
primaryChokepointId: string,
): { fromPort: readonly [number, number]; toPort: readonly [number, number] } {
const explicit = BYPASS_CORRIDOR_GEOMETRY_BY_ID[corridorId];
if (explicit) return explicit;
const cp = CHOKEPOINT_REGISTRY.find((c) => c.id === primaryChokepointId);
if (cp) {
const pt: readonly [number, number] = [cp.lon, cp.lat];
return { fromPort: pt, toPort: pt };
}
const zero: readonly [number, number] = [0, 0];
return { fromPort: zero, toPort: zero };
}

View File

@@ -0,0 +1,327 @@
/**
* GET /api/supply-chain/v1/get-route-explorer-lane
*
* Internal wrapper around the vendor-only `route-intelligence` compute. Adds:
* - Browser-callable PRO gating via `premium-paths.ts` (no forceKey API-key gate)
* - `primaryRouteGeometry` polyline for map rendering
* - `fromPort` / `toPort` on every bypass option (so the client can feed
* `MapContainer.setBypassRoutes` directly without its own geometry lookup)
* - `status: 'active' | 'proposed' | 'unavailable'` per corridor, derived
* from the `notes` field to honestly label `kra_canal_future` and
* `black_sea_western_ports`
* - Static `estTransitDaysRange` and `estFreightUsdPerTeuRange` from
* hand-curated tables
* - `noModeledLane: true` when we fell back to the origin's first route
* because origin and destination clusters share no routes
*
* This handler is called through the supply-chain service dispatcher, NOT as
* an edge function — so it receives a `ServerContext` and a typed request.
*/
import type {
ServerContext,
GetRouteExplorerLaneRequest,
GetRouteExplorerLaneResponse,
GeoPoint,
CorridorStatus,
BypassCorridorOption,
ChokepointExposureSummary,
NumberRange,
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { isCallerPremium } from '../../../_shared/premium-check';
import { cachedFetchJson, getCachedJson } from '../../../_shared/redis';
import { ROUTE_EXPLORER_LANE_KEY } from '../../../_shared/cache-keys';
import { CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys';
import { CHOKEPOINT_REGISTRY } from '../../../_shared/chokepoint-registry';
import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../_shared/bypass-corridors';
import type { BypassCorridor, CargoType } from '../../../_shared/bypass-corridors';
import { TIER_RANK } from './_insurance-tier';
import COUNTRY_PORT_CLUSTERS from '../../../../scripts/shared/country-port-clusters.json';
import { TRADE_ROUTES } from '../../../../src/config/trade-routes';
import { PORTS } from '../../../../src/config/ports';
import {
TRANSIT_DAYS_BY_ROUTE_ID,
TRANSIT_DAYS_FALLBACK,
FREIGHT_USD_BY_CARGO_TYPE,
FREIGHT_USD_FALLBACK,
getCorridorGeometryOrFallback,
} from './_route-explorer-static-tables';
const CACHE_TTL_SECONDS = 60; // matches vendor endpoint cadence
interface PortClusterEntry {
nearestRouteIds: string[];
coastSide: string;
}
interface ChokepointStatus {
id: string;
name?: string;
disruptionScore?: number;
warRiskTier?: string;
}
interface ChokepointStatusResponse {
chokepoints?: ChokepointStatus[];
}
const CARGO_TYPES = new Set(['container', 'tanker', 'bulk', 'roro']);
const CARGO_TO_ROUTE_CATEGORY: Record<string, string> = {
container: 'container',
tanker: 'energy',
bulk: 'bulk',
roro: 'container',
};
function rankSharedRoutesByCargo(
sharedRoutes: string[],
cargoType: string,
): string[] {
const preferredCategory = CARGO_TO_ROUTE_CATEGORY[cargoType] ?? 'container';
const routeMap = new Map(TRADE_ROUTES.map((r) => [r.id, r]));
return [...sharedRoutes].sort((a, b) => {
const catA = routeMap.get(a)?.category ?? '';
const catB = routeMap.get(b)?.category ?? '';
const matchA = catA === preferredCategory ? 0 : 1;
const matchB = catB === preferredCategory ? 0 : 1;
return matchA - matchB;
});
}
function emptyResponse(
req: GetRouteExplorerLaneRequest,
fallbackHs2: string,
fallbackCargo: string,
): GetRouteExplorerLaneResponse {
return {
fromIso2: req.fromIso2,
toIso2: req.toIso2,
hs2: fallbackHs2,
cargoType: fallbackCargo,
primaryRouteId: '',
primaryRouteGeometry: [],
chokepointExposures: [],
bypassOptions: [],
warRiskTier: 'WAR_RISK_TIER_NORMAL',
disruptionScore: 0,
noModeledLane: true,
fetchedAt: new Date().toISOString(),
};
}
function rangeOf(tuple: readonly [number, number]): NumberRange {
return { min: tuple[0], max: tuple[1] };
}
function geoPoint(lon: number, lat: number): GeoPoint {
return { lon, lat };
}
/**
* Resolve coordinates for a `TradeRoute.waypoints` entry. Waypoints are string
* IDs that can refer to either a `PORTS` entry or a chokepoint (via
* `CHOKEPOINT_REGISTRY`). We try both in that order.
*/
function lookupWaypointCoord(waypointId: string): GeoPoint | null {
const port = PORTS.find((p) => p.id === waypointId);
if (port) return geoPoint(port.lon, port.lat);
const cp = CHOKEPOINT_REGISTRY.find((c) => c.id === waypointId);
if (cp) return geoPoint(cp.lon, cp.lat);
return null;
}
/**
* Build the primaryRouteGeometry polyline from a trade-route definition. We
* use `from` → `waypoints[]` → `to` in sequence, dropping any waypoint we
* can't resolve. Returns an empty array when `routeId` is empty or unknown.
*/
function buildRouteGeometry(routeId: string): GeoPoint[] {
if (!routeId) return [];
const route = TRADE_ROUTES.find((r) => r.id === routeId);
if (!route) return [];
const coords: GeoPoint[] = [];
const fromCoord = lookupWaypointCoord(route.from);
if (fromCoord) coords.push(fromCoord);
for (const wp of route.waypoints) {
const c = lookupWaypointCoord(wp);
if (c) coords.push(c);
}
const toCoord = lookupWaypointCoord(route.to);
if (toCoord) coords.push(toCoord);
return coords;
}
/**
* Derive a corridor status from the hand-authored `notes` field on the source
* config. We keep this string-matching intentionally narrow to avoid over-
* classifying as proposed/unavailable — default is ACTIVE.
*/
function deriveCorridorStatus(corridor: BypassCorridor): CorridorStatus {
const notes = (corridor.notes ?? '').toLowerCase();
const name = (corridor.name ?? '').toLowerCase();
if (/proposed|not yet constructed|notional/.test(notes) || /proposed|\(future\)/.test(name)) {
return 'CORRIDOR_STATUS_PROPOSED';
}
if (/blockaded|effectively closed|not usable|suspended/.test(notes)) {
return 'CORRIDOR_STATUS_UNAVAILABLE';
}
return 'CORRIDOR_STATUS_ACTIVE';
}
function deriveBypassWarRiskTier(
corridor: BypassCorridor,
statusMap: Map<string, ChokepointStatus>,
): string {
if (corridor.waypointChokepointIds.length > 0) {
return corridor.waypointChokepointIds.reduce<string>((best, id) => {
const t = statusMap.get(id)?.warRiskTier ?? 'WAR_RISK_TIER_UNSPECIFIED';
return (TIER_RANK[t] ?? 0) > (TIER_RANK[best] ?? 0) ? t : best;
}, 'WAR_RISK_TIER_UNSPECIFIED');
}
const status = deriveCorridorStatus(corridor);
if (status === 'CORRIDOR_STATUS_UNAVAILABLE') return 'WAR_RISK_TIER_WAR_ZONE';
return 'WAR_RISK_TIER_UNSPECIFIED';
}
function buildBypassOption(
corridor: BypassCorridor,
primaryChokepointId: string,
statusMap: Map<string, ChokepointStatus>,
): BypassCorridorOption {
const geom = getCorridorGeometryOrFallback(corridor.id, primaryChokepointId);
return {
id: corridor.id,
name: corridor.name,
type: corridor.type,
addedTransitDays: corridor.addedTransitDays,
addedCostMultiplier: corridor.addedCostMultiplier,
warRiskTier: deriveBypassWarRiskTier(corridor, statusMap),
status: deriveCorridorStatus(corridor),
fromPort: geoPoint(geom.fromPort[0], geom.fromPort[1]),
toPort: geoPoint(geom.toPort[0], geom.toPort[1]),
};
}
/**
* Pure compute function used by the handler and exposed for tests. Does not
* consult premium gating or the response cache. Callers must provide live
* chokepoint status via the parameter; in production the handler fetches it
* from Redis.
*/
export async function computeLane(
req: GetRouteExplorerLaneRequest,
injectedStatusMap?: Map<string, ChokepointStatus>,
): Promise<GetRouteExplorerLaneResponse> {
const fromIso2 = req.fromIso2.trim().toUpperCase();
const toIso2 = req.toIso2.trim().toUpperCase();
const hs2 = req.hs2.trim().replace(/\D/g, '') || '27';
const cargoLower = req.cargoType.trim().toLowerCase();
const cargoType = CARGO_TYPES.has(cargoLower) ? cargoLower : 'container';
if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) {
return emptyResponse(req, hs2, cargoType);
}
const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record<string, PortClusterEntry>;
const fromCluster = clusters[fromIso2];
const toCluster = clusters[toIso2];
const fromRoutes = new Set(fromCluster?.nearestRouteIds ?? []);
const toRoutes = new Set(toCluster?.nearestRouteIds ?? []);
const sharedRoutes = [...fromRoutes].filter((r) => toRoutes.has(r));
const noModeledLane = sharedRoutes.length === 0;
const rankedRoutes = rankSharedRoutesByCargo(sharedRoutes, cargoType);
const primaryRouteId = rankedRoutes[0] ?? fromCluster?.nearestRouteIds[0] ?? '';
let statusMap: Map<string, ChokepointStatus>;
if (injectedStatusMap) {
statusMap = injectedStatusMap;
} else {
const statusRaw = (await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(
() => null,
)) as ChokepointStatusResponse | null;
statusMap = new Map<string, ChokepointStatus>(
(statusRaw?.chokepoints ?? []).map((cp) => [cp.id, cp]),
);
}
const primaryRouteSet = new Set(primaryRouteId ? [primaryRouteId] : []);
const chokepointExposures: ChokepointExposureSummary[] = CHOKEPOINT_REGISTRY
.filter((cp) => cp.routeIds.some((r) => primaryRouteSet.has(r)))
.map((cp) => {
const overlap = cp.routeIds.filter((r) => primaryRouteSet.has(r)).length;
const exposurePct = Math.round((overlap / Math.max(cp.routeIds.length, 1)) * 100);
return {
chokepointId: cp.id,
chokepointName: cp.displayName,
exposurePct,
};
})
.filter((e) => e.exposurePct > 0)
.sort((a, b) => b.exposurePct - a.exposurePct);
const primaryChokepoint = chokepointExposures[0];
const primaryCpStatus = primaryChokepoint ? statusMap.get(primaryChokepoint.chokepointId) : null;
const disruptionScore = primaryCpStatus?.disruptionScore ?? 0;
const warRiskTier = primaryCpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL';
const PLACEHOLDER_CORRIDOR_IDS = new Set(['gibraltar_no_bypass', 'cape_of_good_hope_is_bypass']);
const bypassOptions: BypassCorridorOption[] = primaryChokepoint
? (BYPASS_CORRIDORS_BY_CHOKEPOINT[primaryChokepoint.chokepointId] ?? [])
.filter((c) => {
if (PLACEHOLDER_CORRIDOR_IDS.has(c.id)) return false;
if (c.suitableCargoTypes.length > 0 && !c.suitableCargoTypes.includes(cargoType as CargoType)) return false;
return true;
})
.slice(0, 5)
.map((c) => buildBypassOption(c, primaryChokepoint.chokepointId, statusMap))
: [];
const transitTuple = TRANSIT_DAYS_BY_ROUTE_ID[primaryRouteId] ?? TRANSIT_DAYS_FALLBACK;
const freightTuple = FREIGHT_USD_BY_CARGO_TYPE[cargoType] ?? FREIGHT_USD_FALLBACK;
return {
fromIso2,
toIso2,
hs2,
cargoType,
primaryRouteId: noModeledLane ? '' : primaryRouteId,
primaryRouteGeometry: noModeledLane ? [] : buildRouteGeometry(primaryRouteId),
chokepointExposures: noModeledLane ? [] : chokepointExposures,
bypassOptions: noModeledLane ? [] : bypassOptions,
warRiskTier: noModeledLane ? 'WAR_RISK_TIER_NORMAL' : warRiskTier,
disruptionScore: noModeledLane ? 0 : disruptionScore,
estTransitDaysRange: noModeledLane ? undefined : rangeOf(transitTuple),
estFreightUsdPerTeuRange: noModeledLane ? undefined : rangeOf(freightTuple),
noModeledLane,
fetchedAt: new Date().toISOString(),
};
}
export async function getRouteExplorerLane(
ctx: ServerContext,
req: GetRouteExplorerLaneRequest,
): Promise<GetRouteExplorerLaneResponse> {
const isPro = await isCallerPremium(ctx.request);
const hs2 = req.hs2?.trim().replace(/\D/g, '') || '27';
const cargo = CARGO_TYPES.has(req.cargoType?.trim().toLowerCase() ?? '')
? req.cargoType.trim().toLowerCase()
: 'container';
if (!isPro) return emptyResponse(req, hs2, cargo);
const fromIso2 = req.fromIso2?.trim().toUpperCase() ?? '';
const toIso2 = req.toIso2?.trim().toUpperCase() ?? '';
if (!/^[A-Z]{2}$/.test(fromIso2) || !/^[A-Z]{2}$/.test(toIso2)) {
return emptyResponse(req, hs2, cargo);
}
const cacheKey = ROUTE_EXPLORER_LANE_KEY(fromIso2, toIso2, hs2, cargo);
const result = await cachedFetchJson<GetRouteExplorerLaneResponse>(
cacheKey,
CACHE_TTL_SECONDS,
async () => computeLane({ fromIso2, toIso2, hs2, cargoType: cargo }),
);
return result ?? emptyResponse(req, hs2, cargo);
}

View File

@@ -8,6 +8,7 @@ import { getCountryChokepointIndex } from './get-country-chokepoint-index';
import { getBypassOptions } from './get-bypass-options';
import { getCountryCostShock } from './get-country-cost-shock';
import { getSectorDependency } from './get-sector-dependency';
import { getRouteExplorerLane } from './get-route-explorer-lane';
export const supplyChainHandler: SupplyChainServiceHandler = {
getShippingRates,
@@ -18,4 +19,5 @@ export const supplyChainHandler: SupplyChainServiceHandler = {
getBypassOptions,
getCountryCostShock,
getSectorDependency,
getRouteExplorerLane,
};

View File

@@ -236,6 +236,60 @@ export interface GetSectorDependencyResponse {
fetchedAt: string;
}
export interface GetRouteExplorerLaneRequest {
fromIso2: string;
toIso2: string;
hs2: string;
cargoType: string;
}
export interface GetRouteExplorerLaneResponse {
fromIso2: string;
toIso2: string;
hs2: string;
cargoType: string;
primaryRouteId: string;
primaryRouteGeometry: GeoPoint[];
chokepointExposures: ChokepointExposureSummary[];
bypassOptions: BypassCorridorOption[];
warRiskTier: string;
disruptionScore: number;
estTransitDaysRange?: NumberRange;
estFreightUsdPerTeuRange?: NumberRange;
noModeledLane: boolean;
fetchedAt: string;
}
export interface GeoPoint {
lon: number;
lat: number;
}
export interface ChokepointExposureSummary {
chokepointId: string;
chokepointName: string;
exposurePct: number;
}
export interface BypassCorridorOption {
id: string;
name: string;
type: string;
addedTransitDays: number;
addedCostMultiplier: number;
warRiskTier: string;
status: CorridorStatus;
fromPort?: GeoPoint;
toPort?: GeoPoint;
}
export interface NumberRange {
min: number;
max: number;
}
export type CorridorStatus = "CORRIDOR_STATUS_UNSPECIFIED" | "CORRIDOR_STATUS_ACTIVE" | "CORRIDOR_STATUS_PROPOSED" | "CORRIDOR_STATUS_UNAVAILABLE";
export type DependencyFlag = "DEPENDENCY_FLAG_UNSPECIFIED" | "DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL" | "DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL" | "DEPENDENCY_FLAG_COMPOUND_RISK" | "DEPENDENCY_FLAG_DIVERSIFIABLE";
export type WarRiskTier = "WAR_RISK_TIER_UNSPECIFIED" | "WAR_RISK_TIER_NORMAL" | "WAR_RISK_TIER_ELEVATED" | "WAR_RISK_TIER_HIGH" | "WAR_RISK_TIER_CRITICAL" | "WAR_RISK_TIER_WAR_ZONE";
@@ -486,6 +540,34 @@ export class SupplyChainServiceClient {
return await resp.json() as GetSectorDependencyResponse;
}
async getRouteExplorerLane(req: GetRouteExplorerLaneRequest, options?: SupplyChainServiceCallOptions): Promise<GetRouteExplorerLaneResponse> {
let path = "/api/supply-chain/v1/get-route-explorer-lane";
const params = new URLSearchParams();
if (req.fromIso2 != null && req.fromIso2 !== "") params.set("fromIso2", String(req.fromIso2));
if (req.toIso2 != null && req.toIso2 !== "") params.set("toIso2", String(req.toIso2));
if (req.hs2 != null && req.hs2 !== "") params.set("hs2", String(req.hs2));
if (req.cargoType != null && req.cargoType !== "") params.set("cargoType", String(req.cargoType));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetRouteExplorerLaneResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -236,6 +236,60 @@ export interface GetSectorDependencyResponse {
fetchedAt: string;
}
export interface GetRouteExplorerLaneRequest {
fromIso2: string;
toIso2: string;
hs2: string;
cargoType: string;
}
export interface GetRouteExplorerLaneResponse {
fromIso2: string;
toIso2: string;
hs2: string;
cargoType: string;
primaryRouteId: string;
primaryRouteGeometry: GeoPoint[];
chokepointExposures: ChokepointExposureSummary[];
bypassOptions: BypassCorridorOption[];
warRiskTier: string;
disruptionScore: number;
estTransitDaysRange?: NumberRange;
estFreightUsdPerTeuRange?: NumberRange;
noModeledLane: boolean;
fetchedAt: string;
}
export interface GeoPoint {
lon: number;
lat: number;
}
export interface ChokepointExposureSummary {
chokepointId: string;
chokepointName: string;
exposurePct: number;
}
export interface BypassCorridorOption {
id: string;
name: string;
type: string;
addedTransitDays: number;
addedCostMultiplier: number;
warRiskTier: string;
status: CorridorStatus;
fromPort?: GeoPoint;
toPort?: GeoPoint;
}
export interface NumberRange {
min: number;
max: number;
}
export type CorridorStatus = "CORRIDOR_STATUS_UNSPECIFIED" | "CORRIDOR_STATUS_ACTIVE" | "CORRIDOR_STATUS_PROPOSED" | "CORRIDOR_STATUS_UNAVAILABLE";
export type DependencyFlag = "DEPENDENCY_FLAG_UNSPECIFIED" | "DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL" | "DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL" | "DEPENDENCY_FLAG_COMPOUND_RISK" | "DEPENDENCY_FLAG_DIVERSIFIABLE";
export type WarRiskTier = "WAR_RISK_TIER_UNSPECIFIED" | "WAR_RISK_TIER_NORMAL" | "WAR_RISK_TIER_ELEVATED" | "WAR_RISK_TIER_HIGH" | "WAR_RISK_TIER_CRITICAL" | "WAR_RISK_TIER_WAR_ZONE";
@@ -293,6 +347,7 @@ export interface SupplyChainServiceHandler {
getBypassOptions(ctx: ServerContext, req: GetBypassOptionsRequest): Promise<GetBypassOptionsResponse>;
getCountryCostShock(ctx: ServerContext, req: GetCountryCostShockRequest): Promise<GetCountryCostShockResponse>;
getSectorDependency(ctx: ServerContext, req: GetSectorDependencyRequest): Promise<GetSectorDependencyResponse>;
getRouteExplorerLane(ctx: ServerContext, req: GetRouteExplorerLaneRequest): Promise<GetRouteExplorerLaneResponse>;
}
export function createSupplyChainServiceRoutes(
@@ -642,6 +697,56 @@ export function createSupplyChainServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/supply-chain/v1/get-route-explorer-lane",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: GetRouteExplorerLaneRequest = {
fromIso2: params.get("fromIso2") ?? "",
toIso2: params.get("toIso2") ?? "",
hs2: params.get("hs2") ?? "",
cargoType: params.get("cargoType") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getRouteExplorerLane", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getRouteExplorerLane(ctx, body);
return new Response(JSON.stringify(result as GetRouteExplorerLaneResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -10,6 +10,7 @@ import {
type GetBypassOptionsResponse,
type GetCountryCostShockResponse,
type GetSectorDependencyResponse,
type GetRouteExplorerLaneResponse,
type ShippingIndex,
type ChokepointInfo,
type CriticalMineral,
@@ -30,6 +31,7 @@ export type {
GetBypassOptionsResponse,
GetCountryCostShockResponse,
GetSectorDependencyResponse,
GetRouteExplorerLaneResponse,
ShippingIndex,
ChokepointInfo,
CriticalMineral,
@@ -226,6 +228,35 @@ export async function fetchSectorDependency(
}
}
const emptyRouteExplorerLane: GetRouteExplorerLaneResponse = {
fromIso2: '', toIso2: '', hs2: '', cargoType: '',
primaryRouteId: '',
primaryRouteGeometry: [],
chokepointExposures: [],
bypassOptions: [],
warRiskTier: 'WAR_RISK_TIER_NORMAL',
disruptionScore: 0,
noModeledLane: true,
fetchedAt: '',
};
export interface FetchRouteExplorerLaneArgs {
fromIso2: string;
toIso2: string;
hs2: string;
cargoType: string;
}
export async function fetchRouteExplorerLane(
args: FetchRouteExplorerLaneArgs,
): Promise<GetRouteExplorerLaneResponse> {
try {
return await client.getRouteExplorerLane(args);
} catch {
return { ...emptyRouteExplorerLane, ...args };
}
}
export interface ProductExporter {
partnerCode: number;
partnerIso2: string;

View File

@@ -19,6 +19,7 @@ export const PREMIUM_RPC_PATHS = new Set<string>([
'/api/supply-chain/v1/get-country-chokepoint-index',
'/api/supply-chain/v1/get-bypass-options',
'/api/supply-chain/v1/get-country-cost-shock',
'/api/supply-chain/v1/get-route-explorer-lane',
'/api/supply-chain/v1/multi-sector-cost-shock',
'/api/economic/v1/get-national-debt',
'/api/sanctions/v1/list-sanctions-pressure',

View File

@@ -0,0 +1,325 @@
/**
* Smoke test matrix for `get-route-explorer-lane`.
*
* Calls the pure `computeLane` function (no Redis, no premium gate) for 30
* representative country pairs × HS2 codes and asserts on response *structure*
* — not hard-coded transit/cost values, which would drift as the underlying
* static tables change.
*
* The matrix also doubles as a gap report: any pair with empty
* `chokepointExposures` or `bypassOptions` is logged so Sprint 3/5 can plan
* empty-state work.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { computeLane } from '../server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts';
import type { GetRouteExplorerLaneRequest } from '../src/generated/server/worldmonitor/supply_chain/v1/service_server.ts';
const PAIRS: Array<[string, string, string]> = [
['CN', 'DE', 'high-volume baseline'],
['US', 'JP', 'transpacific'],
['IR', 'CN', 'Hormuz-dependent'],
['BR', 'NL', 'Atlantic'],
['AU', 'KR', 'Pacific'],
['ZA', 'IN', 'Cape of Good Hope'],
['EG', 'IT', 'Mediterranean short-haul'],
['NG', 'CN', 'Africa to Asia crude'],
['CL', 'CN', 'South America to Asia copper'],
['TR', 'DE', 'semi-landlocked, tests land-bridge path'],
];
const HS2_CODES = ['27', '85', '10'];
const VALID_WAR_RISK_TIERS = new Set([
'WAR_RISK_TIER_UNSPECIFIED',
'WAR_RISK_TIER_NORMAL',
'WAR_RISK_TIER_ELEVATED',
'WAR_RISK_TIER_HIGH',
'WAR_RISK_TIER_CRITICAL',
'WAR_RISK_TIER_WAR_ZONE',
]);
const VALID_STATUSES = new Set([
'CORRIDOR_STATUS_UNSPECIFIED',
'CORRIDOR_STATUS_ACTIVE',
'CORRIDOR_STATUS_PROPOSED',
'CORRIDOR_STATUS_UNAVAILABLE',
]);
interface GapRow {
pair: string;
hs2: string;
primaryRouteId: string;
noModeledLane: boolean;
exposures: number;
bypasses: number;
reason: string;
}
const gapRows: GapRow[] = [];
describe('get-route-explorer-lane smoke matrix (30 queries)', () => {
for (const [fromIso2, toIso2, reason] of PAIRS) {
for (const hs2 of HS2_CODES) {
it(`${fromIso2} -> ${toIso2}, HS ${hs2} (${reason})`, async () => {
const req: GetRouteExplorerLaneRequest = {
fromIso2,
toIso2,
hs2,
cargoType: hs2 === '27' ? 'tanker' : hs2 === '10' ? 'bulk' : 'container',
};
// Pass an empty chokepoint-status map so the test does not depend on
// a live Redis cache. War risk + disruption come back as defaults.
const res = await computeLane(req, new Map());
// Echoed inputs
assert.equal(res.fromIso2, fromIso2);
assert.equal(res.toIso2, toIso2);
assert.equal(res.hs2, hs2);
// Cargo type echoed and valid
assert.match(res.cargoType, /^(container|tanker|bulk|roro)$/);
// primaryRouteId is either non-empty OR noModeledLane is set
if (!res.primaryRouteId) {
assert.equal(
res.noModeledLane,
true,
'empty primaryRouteId requires noModeledLane=true',
);
}
// primaryRouteGeometry is an array (may be empty when no modeled lane)
assert.ok(Array.isArray(res.primaryRouteGeometry));
for (const pt of res.primaryRouteGeometry) {
assert.equal(typeof pt.lon, 'number');
assert.equal(typeof pt.lat, 'number');
assert.ok(Number.isFinite(pt.lon));
assert.ok(Number.isFinite(pt.lat));
}
// chokepointExposures is an array of well-formed entries
assert.ok(Array.isArray(res.chokepointExposures));
for (const e of res.chokepointExposures) {
assert.equal(typeof e.chokepointId, 'string');
assert.equal(typeof e.chokepointName, 'string');
assert.equal(typeof e.exposurePct, 'number');
assert.ok(e.exposurePct >= 0 && e.exposurePct <= 100);
}
// bypassOptions is an array of well-formed entries
assert.ok(Array.isArray(res.bypassOptions));
for (const b of res.bypassOptions) {
assert.equal(typeof b.id, 'string');
assert.equal(typeof b.name, 'string');
assert.equal(typeof b.type, 'string');
assert.equal(typeof b.addedTransitDays, 'number');
assert.equal(typeof b.addedCostMultiplier, 'number');
assert.ok(VALID_STATUSES.has(b.status));
assert.ok(b.fromPort, 'bypass option must include fromPort');
assert.ok(b.toPort, 'bypass option must include toPort');
assert.equal(typeof b.fromPort.lon, 'number');
assert.equal(typeof b.fromPort.lat, 'number');
assert.equal(typeof b.toPort.lon, 'number');
assert.equal(typeof b.toPort.lat, 'number');
assert.ok(Number.isFinite(b.fromPort.lon));
assert.ok(Number.isFinite(b.toPort.lon));
}
// war risk tier is in the known enum set
assert.ok(
VALID_WAR_RISK_TIERS.has(res.warRiskTier),
`unexpected warRiskTier: ${res.warRiskTier}`,
);
// disruption score is a finite number in [0, 100]
assert.equal(typeof res.disruptionScore, 'number');
assert.ok(res.disruptionScore >= 0 && res.disruptionScore <= 100);
// transit + freight ranges: present and well-formed when lane is modeled;
// omitted when noModeledLane is true (no synthetic estimates)
if (!res.noModeledLane) {
assert.ok(res.estTransitDaysRange, 'modeled lane must include transit range');
assert.ok(res.estFreightUsdPerTeuRange, 'modeled lane must include freight range');
assert.ok(Number.isFinite(res.estTransitDaysRange.min));
assert.ok(Number.isFinite(res.estTransitDaysRange.max));
assert.ok(res.estTransitDaysRange.min <= res.estTransitDaysRange.max);
assert.ok(res.estFreightUsdPerTeuRange.min <= res.estFreightUsdPerTeuRange.max);
} else {
assert.equal(res.primaryRouteId, '', 'noModeledLane must have empty primaryRouteId');
assert.equal(res.primaryRouteGeometry.length, 0, 'noModeledLane must have empty geometry');
assert.equal(res.chokepointExposures.length, 0, 'noModeledLane must have empty exposures');
assert.equal(res.bypassOptions.length, 0, 'noModeledLane must have empty bypasses');
}
// fetchedAt is an ISO string
assert.equal(typeof res.fetchedAt, 'string');
assert.ok(res.fetchedAt.length > 0);
// Record gap-report metadata for the run summary
gapRows.push({
pair: `${fromIso2}->${toIso2}`,
hs2,
primaryRouteId: res.primaryRouteId,
noModeledLane: res.noModeledLane,
exposures: res.chokepointExposures.length,
bypasses: res.bypassOptions.length,
reason,
});
});
}
}
it('gap report summary (informational, never fails)', () => {
// Print a compact gap report so plan reviewers can see which pairs
// returned synthetic / empty data.
if (gapRows.length === 0) {
// No-op when run before the matrix above (test ordering is preserved)
return;
}
const noLane = gapRows.filter((r) => r.noModeledLane);
const emptyExposures = gapRows.filter((r) => r.exposures === 0);
const emptyBypasses = gapRows.filter((r) => r.bypasses === 0);
// eslint-disable-next-line no-console
console.log(
`\n[gap report] ${gapRows.length} queries | ${noLane.length} synthetic-fallback | ${emptyExposures.length} empty exposures | ${emptyBypasses.length} empty bypasses`,
);
if (noLane.length > 0) {
// eslint-disable-next-line no-console
console.log(' synthetic-fallback pairs:');
for (const r of noLane) {
// eslint-disable-next-line no-console
console.log(` ${r.pair} HS${r.hs2} -> ${r.primaryRouteId || '(none)'}`);
}
}
// eslint-disable-next-line no-console
console.log(
'\n[design gap] bypassOptions are only computed for the primary chokepoint (highest exposurePct).' +
'\nMulti-chokepoint routes (e.g. CN->DE via Malacca + Suez) show exposure data for both but' +
'\nbypass guidance only for the primary one. Sprint 3 should decide: expand to top-N chokepoints,' +
'\nor show a "see also" hint in the UI.',
);
// Always passes; informational only.
assert.ok(true);
});
it('cargo-aware route selection: CN->JP tanker picks energy route over container', async () => {
const res = await computeLane(
{ fromIso2: 'CN', toIso2: 'JP', hs2: '27', cargoType: 'tanker' },
new Map(),
);
if (!res.noModeledLane && res.primaryRouteId) {
const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts');
const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId);
assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`);
assert.equal(
route.category,
'energy',
`tanker request should prefer an energy route, got ${route.category} (${res.primaryRouteId})`,
);
}
});
it('bypass warRiskTier derives from waypoint chokepoints, not primary', async () => {
const fakeStatus = new Map<string, { id: string; warRiskTier: string }>([
['suez', { id: 'suez', warRiskTier: 'WAR_RISK_TIER_CRITICAL' }],
['cape_of_good_hope', { id: 'cape_of_good_hope', warRiskTier: 'WAR_RISK_TIER_NORMAL' }],
]);
const res = await computeLane(
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
fakeStatus as Map<string, any>,
);
const capeBypass = res.bypassOptions.find((b) => b.id === 'suez_cape_of_good_hope');
if (capeBypass) {
assert.equal(
capeBypass.warRiskTier,
'WAR_RISK_TIER_NORMAL',
'Cape bypass should reflect its own waypoint risk (NORMAL), not the primary chokepoint (CRITICAL)',
);
}
});
it('placeholder corridors are excluded but proposed zero-day corridors survive', async () => {
const res = await computeLane(
{ fromIso2: 'ES', toIso2: 'EG', hs2: '85', cargoType: 'container' },
new Map(),
);
const placeholder = res.bypassOptions.find((b) =>
b.id === 'gibraltar_no_bypass' || b.id === 'cape_of_good_hope_is_bypass',
);
assert.equal(placeholder, undefined, 'explicit placeholder corridors should be filtered out');
});
it('kra_canal_future appears as CORRIDOR_STATUS_PROPOSED for Malacca routes', async () => {
const res = await computeLane(
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
new Map(),
);
const kra = res.bypassOptions.find((b) => b.id === 'kra_canal_future');
if (kra) {
assert.equal(
kra.status,
'CORRIDOR_STATUS_PROPOSED',
'kra_canal_future should be surfaced as proposed, not filtered out',
);
}
});
it('disruptionScore and warRiskTier reflect injected status map', async () => {
const fakeStatus = new Map<string, { id: string; disruptionScore?: number; warRiskTier?: string }>([
['suez', { id: 'suez', disruptionScore: 75, warRiskTier: 'WAR_RISK_TIER_HIGH' }],
['malacca_strait', { id: 'malacca_strait', disruptionScore: 30, warRiskTier: 'WAR_RISK_TIER_ELEVATED' }],
]);
const res = await computeLane(
{ fromIso2: 'CN', toIso2: 'DE', hs2: '85', cargoType: 'container' },
fakeStatus as Map<string, any>,
);
if (res.noModeledLane) return;
assert.ok(res.disruptionScore > 0, 'disruptionScore should reflect injected data, not default to 0');
assert.notEqual(res.warRiskTier, 'WAR_RISK_TIER_NORMAL', 'warRiskTier should reflect injected data');
});
it('unavailable corridor without waypoints gets WAR_RISK_TIER_WAR_ZONE', async () => {
const fakeStatus = new Map<string, { id: string; warRiskTier?: string }>([
['kerch_strait', { id: 'kerch_strait', warRiskTier: 'WAR_RISK_TIER_WAR_ZONE' }],
]);
const res = await computeLane(
{ fromIso2: 'RU', toIso2: 'TR', hs2: '27', cargoType: 'tanker' },
fakeStatus as Map<string, any>,
);
const unavailable = res.bypassOptions.find((b) => b.status === 'CORRIDOR_STATUS_UNAVAILABLE');
if (unavailable) {
assert.equal(
unavailable.warRiskTier,
'WAR_RISK_TIER_WAR_ZONE',
'unavailable corridors without waypoints should derive WAR_ZONE from status',
);
}
});
it('chokepointExposures and bypassOptions follow the primaryRouteId', async () => {
const res = await computeLane(
{ fromIso2: 'CN', toIso2: 'JP', hs2: '85', cargoType: 'container' },
new Map(),
);
if (res.noModeledLane || !res.primaryRouteId) return;
const { TRADE_ROUTES } = await import('../src/config/trade-routes.ts');
const { CHOKEPOINT_REGISTRY } = await import('../server/_shared/chokepoint-registry.ts');
const route = TRADE_ROUTES.find((r: { id: string }) => r.id === res.primaryRouteId);
assert.ok(route, `primaryRouteId ${res.primaryRouteId} not in TRADE_ROUTES`);
const routeChokepointIds = new Set(
CHOKEPOINT_REGISTRY
.filter((cp: { routeIds: string[] }) => cp.routeIds.includes(res.primaryRouteId))
.map((cp: { id: string }) => cp.id),
);
for (const exp of res.chokepointExposures) {
assert.ok(
routeChokepointIds.has(exp.chokepointId),
`chokepoint ${exp.chokepointId} is not on the primary route ${res.primaryRouteId}`,
);
}
});
});