Skip to content

Phase 2 PR2: Mobility v1 population (airports/airspace/reroute/NOTAM)#2976

Merged
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-mobility
Apr 11, 2026
Merged

Phase 2 PR2: Mobility v1 population (airports/airspace/reroute/NOTAM)#2976
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-mobility

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2 PR2 of the Regional Intelligence Model. Replaces the Phase 0 empty `mobility` stub with a real `MobilityState` built entirely from existing Redis inputs. No new seeders, no new API keys, no new dependencies.

What landed

New module: `scripts/regional-snapshot/mobility.mjs`

Single public entry point: `buildMobilityState(regionId, sources) -> MobilityState`. Pure, never throws, wrapped in a defensive try/catch so snapshot persist can never be blocked by a mobility builder bug.

Data sources (all already in the repo)

Key Coverage Used for
`aviation:delays:faa:v1` US airports `airports[]` (NA)
`aviation:delays:intl:v3` ~51 non-US airports worldwide `airports[]` (all other regions)
`aviation:notam:closures:v2` Global ICAO NOTAM closures `notam_closures[]`
`intelligence:gpsjam:v2` Global GPS jamming hexes `airspace[]`
`military:flights:v1` Global military ADSB tracks `reroute_intensity` proxy

Per-field output mapping

Field Source Coverage
`airspace[]` GPS-jam hexes aggregated 1:region All 7 regions
`flight_corridors[]` (intentionally empty in v1)
`airports[]` FAA + intl delays, severity ≥ MAJOR All 7 regions
`reroute_intensity` `clip(militaryCount / 50, 0, 1)` All 7 regions
`notam_closures[]` NOTAM reasons for region airports All 7 regions

Region classification helpers

Three pure classifiers handle the three different region conventions the source data uses:

  • `airportToSnapshotRegion(alert)` — splits AviationStack `AIRPORT_REGION_*` enum by country so `AMERICAS` → {`north-america`, `latam`} and `APAC` → {`south-asia`, `east-asia`}
  • `gpsjamRegionToSnapshotRegion(label)` — maps the 17 `fetch-gpsjam.mjs` region labels (iran-iraq, levant, ukraine-russia, etc.) to the 7 snapshot regions. Turkey/Caucasus intentionally lands in MENA per the geography.js WB override.
  • `latLonToSnapshotRegion(lat, lon)` — bbox classifier for military flights. Coarse but shippable; v2 could use h3/geometry.

Key design decisions

  1. Airspace aggregation: v1 emits ONE `AirspaceStatus` per region instead of one per GPS-jam hex. Emitting per-hex would flood the UI (MENA currently has 40+ hexes). The aggregated entry's `reason` lists sub-region names and high/med/low counts so analysts can still drill into the underlying data via the evidence chain.

  2. Reroute intensity proxy: Military flight count is crude but defensible as a civil-rerouting pressure signal. A dedicated civil ADSB track-diversion pipeline would be more rigorous, but this gets us a defensible 0-1 scalar today. Documented inline.

  3. Airport severity filter: Only surfaces alerts at MAJOR or SEVERE to keep the list focused on actionable state. SEVERE maps to `closed`, MAJOR maps to `disrupted`.

Freshness registry

Added 5 new input keys to `FRESHNESS_REGISTRY` so the snapshot's `missing_inputs` / `stale_inputs` classification tracks mobility sources. Per-key `maxAgeMin` set to 2x each source's cron cadence.

Testing

35 new unit tests in `tests/regional-snapshot-mobility.test.mjs`:

  • `airportToSnapshotRegion` (5): US/CA/MX → NA, LatAm countries → latam, APAC split by country, europe/mena/africa direct, case insensitivity, null safety

  • `gpsjamRegionToSnapshotRegion` (4): 17 labels mapped correctly, "other"/unknown → null

  • `latLonToSnapshotRegion` (3): major cities classified correctly, oceans → null, invalid inputs → null

  • `buildAirports` (5): empty sources, region filter, severity filter, severity → status mapping, reason passthrough

  • `buildAirspace` (4): empty, region-scoped aggregation, no-match returns empty, low-level still restricted

  • `buildRerouteIntensity` (4): zero, region-scoped count, saturation at 1.0, missing coords ignored

  • `buildNotamClosures` (4): empty, region attribution via airport alerts, unattributable ICAOs skipped, long-reason truncation

  • `buildMobilityState` (4): full shape matches proto, all-empty safe, malformed inputs don't throw, region isolation across all fields

  • `npm run test:data`: 4537/4537 pass

  • `npm run typecheck` + `typecheck:api`: clean

  • Scripts JSDoc typecheck: clean on touched files

  • `biome lint` on touched files: clean

Deferred to future iterations

  • Direct civil flight track diversion counts per corridor
  • `flight_corridors[]` stress level + `rerouted_flights_24h`
  • LLM-assisted NOTAM structured parse
  • h3 geometry for smarter hex-to-region attribution
  • Mobility-based alert triggers in the diff engine

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • Railway `derived-signals` bundle logs for `[mobility]` warn lines (should be absent under steady state — the builder never throws, but catches any bug and logs).
    • Upstash: after the next cron cycle, `intelligence:snapshot-by-id:v1:mena` (and other regions) should carry non-empty `mobility.airspace` and non-zero `mobility.reroute_intensity`.
    • `meta.missing_inputs` on each snapshot should NOT list the new mobility keys (they're all seeded globally).
  • Validation checks
    • `redis-cli GET intelligence:snapshot:v1:mena:latest` → fetch id → `GET intelligence:snapshot-by-id:v1:` → inspect `mobility.airspace[0].reason` contains GPS-jam hex counts
    • Repeat for `europe`, `east-asia`, `sub-saharan-africa` — each should have populated `airspace[]` OR empty (if GPS jam hexes aren't in that region)
    • `mobility.airports[]` should be non-empty for regions with actively-delayed airports
  • Expected healthy behavior
    • 6+ of 7 regions have some mobility data within one cron cycle
    • No `[mobility]` warn lines
    • Snapshot persist rate unchanged (mobility v1 is additive, no break)
  • Failure signal(s) / rollback trigger
    • `[mobility] ... builder threw` lines appear → investigate shape mismatch
    • Snapshot persist rate regresses → should be impossible (try/catch guards it)
    • All regions report empty `airspace[]` → `intelligence:gpsjam:v2` isn't flowing, check Railway `gpsjam` service
  • Validation window & owner

PR sequence

Phase 2 PR2 of the Regional Intelligence Model. Replaces the Phase 0
empty `mobility` stub with a real MobilityState built from existing
Redis inputs — no new seeders, no new API keys, no new dependencies.

## New module: scripts/regional-snapshot/mobility.mjs

Single public entry point:

  buildMobilityState(regionId, sources) -> MobilityState

Pure function. Never throws. Wrapped in a defensive try/catch that
returns the empty shape on any unexpected builder bug so snapshot
persist can never be blocked by mobility data issues.

## Data sources (all already in the repo)

- aviation:delays:faa:v1       — US airports (FAA ASWS, Vercel seeder)
- aviation:delays:intl:v3      — ~51 non-US airports across Europe/APAC/
                                 MENA/Africa/Americas (AviationStack,
                                 Railway ais-relay, 30min refresh)
- aviation:notam:closures:v2   — global ICAO NOTAM closures (FAA-side
                                 seeder hits ICAO API with monitored
                                 ICAO list spanning all regions)
- intelligence:gpsjam:v2       — global GPS jamming hexes (Wingbits,
                                 fetch-gpsjam.mjs)
- military:flights:v1          — global military ADSB tracks (OpenSky,
                                 seed-military-flights.mjs)

## Per-field output mapping

| Field                 | Source                          | Coverage      |
|-----------------------|--------------------------------|---------------|
| airspace[]            | GPS-jam hexes aggregated 1:region | All regions  |
| flight_corridors[]    | (empty in v1)                   | -             |
| airports[]            | FAA + intl delays, severity ≥ MAJOR | All regions |
| reroute_intensity     | clip(military count / 50, 0, 1) | All regions   |
| notam_closures[]      | NOTAM reasons for region airports | All regions  |

## Region classification helpers

Three pure classifiers handle the three different region conventions
the source data uses:

  airportToSnapshotRegion(alert) — splits AviationStack AIRPORT_REGION_*
    by country so americas → {north-america, latam} and apac →
    {south-asia, east-asia}

  gpsjamRegionToSnapshotRegion(label) — maps the 17 fetch-gpsjam.mjs
    classifyRegion labels (iran-iraq, levant, ukraine-russia, etc.) to
    the 7 snapshot regions. Turkey/Caucasus intentionally lands in MENA
    per the geography.js WB override.

  latLonToSnapshotRegion(lat, lon) — bbox classifier for military
    flights. Coarse but shippable; v2 could use h3/geometry.

## Airspace aggregation (P2 design decision)

v1 emits ONE AirspaceStatus per region instead of one per GPS-jam hex.
Emitting per-hex would flood the UI (current MENA data has 40+ hexes).
The aggregated entry's `reason` lists sub-region names and high/med/low
counts so the analyst can still drill into the underlying data via the
evidence chain.

## Reroute intensity caveat

Military flight count is a CRUDE proxy for civil rerouting pressure.
A dedicated civil ADSB track-diversion pipeline would be more rigorous,
but this gets us a defensible 0-1 scalar today. Documented inline.

## Freshness registry

Added 5 new input keys to FRESHNESS_REGISTRY so the snapshot's
missing_inputs / stale_inputs classification tracks mobility sources.
Per-key maxAgeMin set to 2x the source's cron cadence:

  aviation:delays:faa:v1        — 60  min (30min cron)
  aviation:delays:intl:v3       — 90  min (30min cron + Railway jitter)
  aviation:notam:closures:v2    — 120 min (60min cron)
  intelligence:gpsjam:v2        — 240 min (120min cron)
  military:flights:v1           — 30  min (10min cron)

## seed-regional-snapshots.mjs Step 8

Replaced the hardcoded empty stub with a single call:

  const mobility = buildMobilityState(regionId, sources);

No reordering of the compute flow. Pure data, no async.

## Tests — 35 new unit tests

tests/regional-snapshot-mobility.test.mjs:

  airportToSnapshotRegion (5):
    - US/CA/MX → north-america
    - Other Americas → latam
    - IN/PK/BD → south-asia, rest of APAC → east-asia
    - europe/mena/africa direct
    - lowercase labels accepted
    - null/unknown safety

  gpsjamRegionToSnapshotRegion (4): 17 labels + "other" + undefined

  latLonToSnapshotRegion (3): major cities + oceans + invalid inputs

  buildAirports (5): empty sources, region filter, severity filter,
    severity → status (disrupted vs closed), reason passthrough

  buildAirspace (4): empty, region-scoped aggregation, no-match,
    low-level still restricted

  buildRerouteIntensity (4): zero, region-scoped count, saturation,
    missing lat/lon

  buildNotamClosures (4): empty, region attribution via airport alerts,
    unattributable ICAOs skipped, long-reason truncation

  buildMobilityState (4): full shape, all-empty sources, malformed
    inputs don't throw, region isolation across fields

## Verification

- node --test tests/regional-snapshot-mobility.test.mjs: 35/35 pass
- npm run test:data: 4537/4537 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- scripts/jsconfig.json typecheck: clean on touched files
- biome lint: clean on touched files

## Deferred to future iterations

- Direct civil flight track diversion counts per corridor
- flight_corridors[] stress level + rerouted_flights_24h
- LLM-assisted NOTAM structured parse
- h3 geometry for smarter hex-to-region attribution
- Mobility-based alert triggers in the diff engine
@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 11, 2026 9:03pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces mobility.mjs, a new data-population module for the Regional Intelligence snapshot pipeline that derives MobilityState (airports, airspace, reroute_intensity, notam_closures) from five existing Redis inputs with no new dependencies. The implementation is well-structured, purely functional, and defensively guarded — but contains one data-correctness bug.

  • P1 – buildRerouteIntensity null-coordinate misattribution: Number(null) === 0 passes the typeof guard in latLonToSnapshotRegion, so flights with explicit null lat/lon (e.g. position-fix failures from the ADSB feed) resolve to (0, lon) and can be attributed to sub-saharan-africa. The existing test only validates MENA behaviour and misses this case.
  • P2JAM_LEVEL_RANK constant is defined but never referenced; buildAirspace always emits 'restricted' regardless of jam level.
  • P2buildNotamClosures docstring claims NOTAMs track "the same airport scope" as airports[], but icaoToRegion is built from all-severity alerts, so NOTAMs can surface for airports absent from airports[].

Confidence Score: 4/5

Safe to merge after fixing the null-coordinate misattribution in buildRerouteIntensity; all other findings are non-blocking.

One P1 defect exists: null lat/lon values in the military flights feed can inflate reroute_intensity for sub-saharan-africa due to Number(null)===0 bypassing the coordinate guard. The fix is a one-liner null-check. Remaining findings are P2 (dead code, docstring inconsistency) and do not affect correctness.

scripts/regional-snapshot/mobility.mjs — specifically buildRerouteIntensity (line 314–316) and the dead JAM_LEVEL_RANK constant (line 239).

Important Files Changed

Filename Overview
scripts/regional-snapshot/mobility.mjs New module implementing MobilityState from Redis inputs; contains a P1 null-coordinate misattribution bug in buildRerouteIntensity and dead JAM_LEVEL_RANK code.
scripts/regional-snapshot/freshness.mjs Adds 5 new mobility source keys to FRESHNESS_REGISTRY with appropriate maxAgeMin values; clean additive change.
scripts/seed-regional-snapshots.mjs Wires buildMobilityState into the compute pipeline at step 8; integration is clean and correctly positioned before evidence collection.
tests/regional-snapshot-mobility.test.mjs 35 unit tests covering all exported functions; the "ignores flights with missing lat/lon" test passes for MENA but misses the null→SSA misattribution case.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    S[("Redis sources\n(fetched once)")] --> BA[buildAirports]
    S --> BS[buildAirspace]
    S --> BR[buildRerouteIntensity]
    S --> BN[buildNotamClosures]

    BA -->|"aviation:delays:faa:v1\naviation:delays:intl:v3"| R1["filter: severity ≥ MAJOR\nairportToSnapshotRegion()"]
    BS -->|"intelligence:gpsjam:v2"| R2["aggregate hexes per region\ngpsjamRegionToSnapshotRegion()"]
    BR -->|"military:flights:v1"| R3["count flights in region bbox\nlatLonToSnapshotRegion()\n⚠️ null→0 misattribution"]
    BN -->|"aviation:notam:closures:v2\n+ both delay feeds"| R4["cross-ref icaoToRegion\n(all severity levels)\ntruncate reason @200"]

    R1 --> MS["MobilityState\n{ airports[], airspace[],\n  reroute_intensity,\n  notam_closures[],\n  flight_corridors[] }"]
    R2 --> MS
    R3 --> MS
    R4 --> MS

    MS --> SS["RegionalSnapshot\n(persisted to Redis)"]
Loading

Reviews (1): Last reviewed commit: "feat(intelligence): Mobility v1 populati..." | Re-trigger Greptile

Comment thread scripts/regional-snapshot/mobility.mjs
Comment thread scripts/regional-snapshot/mobility.mjs
Comment thread scripts/regional-snapshot/mobility.mjs
P2 #1 — Mexico bbox mismatch between airport and flight classifiers

airportToSnapshotRegion() routes Mexico to north-america by country,
but latLonToSnapshotRegion()'s NA bbox started at lat 20°N. Mexico City
(19.43°N), Guadalajara (20.67°N), and most Mexican airspace fell into
latam, so NA's reroute_intensity understated its actual military
pressure and the two classifiers disagreed on the same country.

Fix: lower the NA bbox southern edge to lat 16.0°N. That captures
every major Mexican city and state capital (Tuxtla Gutiérrez at
16.75°N is the southernmost) while still routing Guatemala City
(14.6°N), San Salvador, Belize, and Honduras to latam. Tiny airports
at lat ~14.9°N (Tapachula in southern Chiapas) route to latam —
acceptable because they're not in the monitored set and don't carry
meaningful military traffic.

Added 8 regression tests asserting classifier parity across major
Mexican cities and correct routing of Central American capitals.

P2 #2 — seed-meta freshness path for undated payloads

classifyInputs() was treating undated payloads as "fresh" via its
fallback branch, so FAA/NOTAM/AviationStack/GPS-jam stalls would
never drag down snapshot_confidence. None of those four payloads
carry a top-level fetchedAt field; the seeders track freshness in
companion seed-meta:* keys only.

Fix: added an optional `metaKey` field to FreshnessRegistry entries.
classifyInputs() now prefers the metaKey companion's fetchedAt when
the spec declares one, falling back to the primary payload's
top-level timestamp (existing behavior) and finally to "fresh"
(existing fallback for legacy undated payloads).

Wired through the full pipeline:
  - freshness.mjs: added metaKey type, ALL_META_KEYS export, and
    metaPayloads parameter to classifyInputs()
  - snapshot-meta.mjs: buildPreMeta() forwards metaSources
  - seed-regional-snapshots.mjs: readAllInputs() now fetches both
    ALL_INPUT_KEYS and ALL_META_KEYS in a single pipeline and
    returns { sources, metaSources }; main() destructures and
    passes metaSources through computeSnapshot → buildPreMeta
  - computeSnapshot() signature gained an optional metaSources arg
    (defaults to {} for back-compat with existing tests)

Registered metaKey for each mobility input:
  aviation:delays:faa:v1       → seed-meta:aviation:faa
  aviation:delays:intl:v3      → seed-meta:aviation:intl
  aviation:notam:closures:v2   → seed-meta:aviation:notam
  intelligence:gpsjam:v2       → seed-meta:intelligence:gpsjam

military:flights:v1 does NOT declare a metaKey because its payload
already carries top-level fetchedAt — this is asserted in a test.

Added 11 regression tests covering:
  - every undated mobility input has a metaKey
  - military:flights:v1 does NOT need a metaKey
  - ALL_META_KEYS aggregation
  - undated payload + fresh meta → fresh
  - undated payload + STALE meta → stale (the core bug fix)
  - undated payload + missing meta → falls back to fresh
  - missing payload → missing regardless of meta
  - legacy payloads with top-level timestamp still work
  - meta without fetchedAt falls through to payload timestamp

## Verification

- node --test tests/regional-snapshot-mobility.test.mjs: 54/54 pass
  (35 original + 19 new regression tests)
- npm run test:data: 4556/4556 pass
- npm run typecheck: clean
- biome lint on touched files: clean
@koala73
koala73 merged commit 6dab59f into main Apr 11, 2026
9 of 10 checks passed
@koala73
koala73 deleted the feat/regional-intelligence-mobility branch April 11, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant