mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
327
server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts
Normal file
327
server/worldmonitor/supply-chain/v1/get-route-explorer-lane.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
325
tests/route-explorer-lane.test.mts
Normal file
325
tests/route-explorer-lane.test.mts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user