Files
suaveplan/openspec/specs/ports/spec.md
2026-05-27 23:29:53 +01:00

187 lines
8.5 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: ports
**Package**: `@suaveplan/ports`
**Status**: PROPOSED
## Requirements
### Requirement: Port lookup by UN/LOCODE
`findPort(locode)` MUST return the `Port` whose `locode` matches the input (case-insensitive) from the built-in `allPorts` registry, or `undefined` when no match exists. A strict variant `findPortStrict(locode)` MUST throw `PortsPortNotFoundError` (`PORTS.PORT_NOT_FOUND`) on miss. Both MUST emit the OTel counter `ports.lookup_total{kind="findPort"}` on every invocation.
#### Scenario: case-insensitive hit returns the curated record
- **WHEN** `findPort("cnsha")` is invoked
- **THEN** the result has `locode === "CNSHA"`, `country === "CN"`, and `type === "sea"`
#### Scenario: strict variant throws on miss
- **WHEN** `findPortStrict("XXNON")` is invoked
- **THEN** a `PortsPortNotFoundError` with code `PORTS.PORT_NOT_FOUND` is thrown
---
### Requirement: Country and type filters
`findPortsByCountry(country, ports?)` MUST return every port whose `country` matches the ISO 3166-1 alpha-2 input (uppercased). `findPortsByType(type, ports?)` MUST filter by `PortType` (`sea | air | rail | multimodal`). `searchPorts(query, ports?)` MUST return every port whose `name` contains the query as a case-insensitive substring. Each function MUST default `ports` to the curated `allPorts` registry.
#### Scenario: country filter excludes other countries
- **WHEN** `findPortsByCountry("nl")` is invoked
- **THEN** every returned port has `country === "NL"` and Rotterdam (`NLRTM`) is present
---
### Requirement: Nearest port with great-circle distance
`findNearestPort(coord, ports?)` MUST return `{ port, distanceKm }` where `port` is the closest port to `coord` by great-circle distance and `distanceKm` is rounded to two decimals. It MUST throw `PortsNoPortsError` (`PORTS.NO_PORTS`) when the input port array is empty. Internally it MUST consult a `PortSpatialIndex` keyed by 5° lat/lng buckets when the input is the default `allPorts` registry, and emit the OTel histogram `ports.find_nearest_duration_ms`.
#### Scenario: nearest sea port to Singapore Strait
- **WHEN** `findNearestPort({ latitude: 1.26, longitude: 103.82 })` is invoked
- **THEN** the returned `port.locode === "SGSIN"` and `distanceKm < 50`
#### Scenario: empty registry rejects
- **WHEN** `findNearestPort(coord, [])` is invoked
- **THEN** a `PortsNoPortsError` with code `PORTS.NO_PORTS` is thrown
---
### Requirement: Radius search
`findPortsInRadius(coord, radiusKm, ports?)` MUST return every port within `radiusKm` great-circle kilometres of `coord`, sorted ascending by `distanceKm`. It MUST throw `PortsInvalidRadiusError` (`PORTS.INVALID_RADIUS`) when `radiusKm <= 0`. Containment MUST be inclusive of the boundary (distance equal to `radiusKm` is included).
#### Scenario: zero radius rejected
- **WHEN** `findPortsInRadius(coord, 0)` is invoked
- **THEN** a `PortsInvalidRadiusError` is thrown with code `PORTS.INVALID_RADIUS`
---
### Requirement: Shipping-lane registry
`getAllShippingLanes()` MUST return the curated `shippingLanes` registry. `getShippingLane(id)` MUST return the lane with matching `id` or `undefined`; `getShippingLaneStrict(id)` MUST throw `PortsLaneNotFoundError` (`PORTS.LANE_NOT_FOUND`) on miss. `getShippingLanesByMode(mode, lanes?)` MUST filter by `LaneMode` (`sea | rail | air`). All lookups MUST emit the OTel counter `ports.lane_lookup_total{mode}`.
#### Scenario: sea-mode filter excludes rail and air lanes
- **WHEN** `getShippingLanesByMode("sea")` is invoked
- **THEN** every returned lane has `mode === "sea"`
---
### Requirement: Lane distance via great-circle waypoints
`estimateLaneDistance(lane)` MUST sum the great-circle distance (nautical miles) between consecutive waypoints in `lane.waypoints`, rounded to two decimals. `cumulativeDistances(lane)` MUST return an array of length `waypoints.length` whose entry `i` is the cumulative nautical-mile distance from `waypoints[0]` to `waypoints[i]` (so entry `0` is `0`). Both MUST throw `PortsInsufficientWaypointsError` (`PORTS.INSUFFICIENT_WAYPOINTS`) when `waypoints.length < 2`.
#### Scenario: insufficient waypoints rejected
- **WHEN** `estimateLaneDistance({ ...lane, waypoints: [waypoints[0]] })` is invoked
- **THEN** a `PortsInsufficientWaypointsError` is thrown
#### Scenario: cumulative distances are monotonically non-decreasing
- **WHEN** `cumulativeDistances(lane)` is invoked
- **THEN** for every consecutive pair `(d[i], d[i+1])`, `d[i+1] >= d[i]` and `d[0] === 0`
---
### Requirement: Project coordinate onto lane
`findPositionOnLane(coord, lane)` MUST locate the nearest waypoint to `coord` and return `{ progress, remainingDistanceNm, distanceTravelledNm, nearestWaypointIndex }`. `progress` MUST be clamped to `[0, 1]` and rounded to four decimals. `remainingDistanceNm` and `distanceTravelledNm` MUST be rounded to two decimals and clamped to `>= 0`. It MUST throw `PortsInsufficientWaypointsError` (`PORTS.INSUFFICIENT_WAYPOINTS`) when `waypoints.length < 2`.
#### Scenario: position exactly on the first waypoint yields zero progress
- **WHEN** `findPositionOnLane(lane.waypoints[0], lane)` is invoked
- **THEN** the result has `nearestWaypointIndex === 0`, `distanceTravelledNm === 0`, and `progress === 0`
---
### Requirement: ETA estimation with injectable clock
`estimateETA(currentCoord, lane, avgSpeedKnots?, opts?)` MUST resolve the effective speed from (in order) the explicit override, `lane.typicalSpeedKnots`, `lane.typicalSpeedKmh / 1.852`. It MUST throw `PortsInvalidSpeedError` (`PORTS.INVALID_SPEED`) when the resolved speed is non-positive and `PortsMissingSpeedError` (`PORTS.MISSING_SPEED`) when no speed source resolves. `etaDate` MUST be computed as `(opts.departureTime ?? new Date(opts.clock?.now() ?? Date.now())) + etaHours hours`. `etaHours` MUST be rounded to two decimals. Confidence MUST be `high | medium | low` derived from deviation between travelled+remaining and total lane distance. The function MUST emit the OTel counter `ports.eta_total{confidence}` and histogram `ports.eta_duration_ms`.
#### Scenario: ETA on a sea lane uses lane default speed
- **WHEN** `estimateETA(lane.waypoints[0], lane)` is invoked with a lane that has `typicalSpeedKnots: 14`
- **THEN** the returned `etaHours > 0` and `confidence === "high"`
#### Scenario: injectable clock controls departure time
- **WHEN** `estimateETA(coord, lane, 14, { clock: { now: () => 0 } })` is invoked
- **THEN** the returned `etaDate.getTime() === etaHours * 3_600_000`
#### Scenario: missing speed sources reject
- **WHEN** `estimateETA(coord, { ...lane, typicalSpeedKnots: undefined, typicalSpeedKmh: undefined })` is invoked without an override
- **THEN** a `PortsMissingSpeedError` with code `PORTS.MISSING_SPEED` is thrown
---
### Requirement: Stable typed error registry
The package MUST export `PORTS_CODES` registered via `defineCodes("PORTS", [...])` covering at least: `NO_PORTS`, `INVALID_RADIUS`, `INSUFFICIENT_WAYPOINTS`, `INVALID_SPEED`, `MISSING_SPEED`, `PORT_NOT_FOUND`, `LANE_NOT_FOUND`, `INVALID_LANE`. Every error thrown by a public function MUST be a `PortsError` subclass whose `.code` matches exactly one entry of the registry.
#### Scenario: every code has a registered subclass
- **WHEN** the suite enumerates `PORTS_CODES`
- **THEN** for each code, a constructable `PortsError` subclass exists whose default `.code` equals that code
---
### Requirement: Zod schemas for public types
The package MUST export Zod schemas (`PortSchema`, `ShippingLaneSchema`, `SphericalCoordSchema`, `LaneProgressSchema`, `ETAEstimateSchema`, `NearestPortResultSchema`) at `./schemas/schemas`. Each schema MUST validate a known good fixture and reject a malformed input.
#### Scenario: PortSchema accepts a curated record
- **WHEN** `PortSchema.parse(seaPortsAsia[0])` is invoked
- **THEN** parsing succeeds without throwing
---
### Requirement: OpenTelemetry instrumentation surface
The package MUST register three counters (`ports.lookup_total`, `ports.lane_lookup_total`, `ports.eta_total`) and three histograms (`ports.find_nearest_duration_ms`, `ports.lane_distance_duration_ms`, `ports.eta_duration_ms`) against the meter `@suaveplan/ports`. `refreshPortsMeters()` MUST re-resolve every instrument against the current global `MeterProvider`.
#### Scenario: refreshPortsMeters rebinds after provider change
- **WHEN** `refreshPortsMeters()` is invoked after setting a new global `MeterProvider`
- **THEN** subsequent counter/histogram emissions are observed by the new provider