From 957d78f7ab9f173dc96e9f222607d3928656f160 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 07:58:48 +0000 Subject: [PATCH 1/3] feat(connection-builder): carrier MCT overrides with SSIM fail-closed hierarchy Implement #141: load curated MCT from data/reference/mct/, prefer carrier overrides, and fail closed when no SSIM-aligned row exists. Remove invented airport-constant / global default tables. Document hierarchy and open DOMAIN_QUESTIONs (online vs interline) in docs/knowledge-base/mct.md. Co-authored-by: telivity-otaip --- .gitignore | 10 +- data/reference/mct/README.md | 26 ++ data/reference/mct/airport-rules.json | 7 + data/reference/mct/carrier-overrides.json | 25 ++ docs/agents/stage-1-search.md | 24 +- docs/knowledge-base/mct.md | 53 +++ .../__tests__/connection-builder.test.ts | 97 +++-- .../connection-builder/connection-scorer.ts | 14 +- .../search/src/connection-builder/index.ts | 25 +- .../search/src/connection-builder/mct-data.ts | 358 ++++++++++-------- .../search/src/connection-builder/types.ts | 15 +- 11 files changed, 449 insertions(+), 205 deletions(-) create mode 100644 data/reference/mct/README.md create mode 100644 data/reference/mct/airport-rules.json create mode 100644 data/reference/mct/carrier-overrides.json create mode 100644 docs/knowledge-base/mct.md diff --git a/.gitignore b/.gitignore index 77e6850..078442a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,13 @@ -# reference data +# reference data (downloaded airports, etc.) /data/* +# curated MCT starter dataset (checked in — see data/reference/mct/) +!/data/reference/ +!/data/reference/.gitkeep +!/data/reference/mct/ +!/data/reference/mct/** +/data/reference/airports.json +/data/reference/metro-areas.json +/data/reference/decommissioned.json # OTAIP run/gate traces (durable JSONL written by @otaip/integration) traces/ diff --git a/data/reference/mct/README.md b/data/reference/mct/README.md new file mode 100644 index 0000000..aaf1c7a --- /dev/null +++ b/data/reference/mct/README.md @@ -0,0 +1,26 @@ +# MCT reference data (`data/reference/mct/`) + +Curated Minimum Connecting Time rows for Agent 1.3 Connection Builder. + +**Authority:** IATA SSIM Chapter 8 + PSC Resolution 765. See `docs/knowledge-base/mct.md`. + +## Hierarchy (most specific → fail-closed) + +| Priority | Level | File / field | When used | +| -------- | ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| 1 | Carrier override | `carrier-overrides.json` | Matching arriving/departing carriers (+ connection status) at airport; optional terminals | +| 2 | Airport + terminal | `airport-rules.json` (`terminal_change` or terminal pair set) | No carrier match; terminal change known | +| 3 | Airport | `airport-rules.json` (station default for status) | No carrier/terminal match | +| 4 | Fail-closed | — | **No invented IATA global table.** MCT unresolved → connection invalid | + +## Rules for adding rows + +- Cite a source (`source` field): SSIM/aggregator extract, public doc URL, or `already-in-code:…`. +- Do **not** add airport-constant guesses or a global 60/90 table. +- Do **not** derive MCT from haversine / great-circle distance. +- Interline / unpublished carrier exceptions → leave as `DOMAIN_QUESTION` in the KB; do not invent. + +## Files + +- `carrier-overrides.json` — carrier@airport starter rows +- `airport-rules.json` — station/terminal rows (empty until a real extract is available) diff --git a/data/reference/mct/airport-rules.json b/data/reference/mct/airport-rules.json new file mode 100644 index 0000000..8f99907 --- /dev/null +++ b/data/reference/mct/airport-rules.json @@ -0,0 +1,7 @@ +{ + "version": "0.1.0", + "description": "Airport / airport+terminal MCT rules from SSIM station filings. Empty until a real extract is curated — do not invent airport constants.", + "authority": ["IATA SSIM Chapter 8", "IATA PSC Resolution 765"], + "hierarchy_levels": ["airport_terminal", "airport"], + "rules": [] +} diff --git a/data/reference/mct/carrier-overrides.json b/data/reference/mct/carrier-overrides.json new file mode 100644 index 0000000..020630b --- /dev/null +++ b/data/reference/mct/carrier-overrides.json @@ -0,0 +1,25 @@ +{ + "version": "0.1.0", + "description": "Carrier-specific MCT overrides (SSIM Ch.8 / PSC Res 765). Starter set only — not a full industry extract.", + "authority": [ + "IATA SSIM Chapter 8", + "IATA PSC Resolution 765", + "https://www.iata.org/contentassets/638f0938b3dd451b872a1d8357755421/minimum-connecting-time-user-guide_version-1.1.pdf" + ], + "hierarchy_level": "carrier_override", + "overrides": [ + { + "airport": "ORD", + "arriving_carrier": "UA", + "departing_carrier": "UA", + "connection_status": "DD", + "connection_type": "domestic", + "scope": "online", + "minutes": 50, + "arrival_terminal": null, + "departure_terminal": null, + "source": "already-in-code: packages/agents/search/src/connection-builder/mct-data.ts carrier_rules (pre-#141)", + "notes": "Online same-carrier domestic at ORD. Not validated against a public SSIM/OAG extract." + } + ] +} diff --git a/docs/agents/stage-1-search.md b/docs/agents/stage-1-search.md index 2ff11d1..5d7723c 100644 --- a/docs/agents/stage-1-search.md +++ b/docs/agents/stage-1-search.md @@ -15,6 +15,7 @@ Flight availability search, schedule lookup, connection validation, fare shoppin Queries distribution adapters in parallel, normalizes, deduplicates, filters, and sorts flight availability offers. **Input (`AvailabilitySearchInput`):** + - `origin` -- origin airport/city IATA code - `destination` -- destination airport/city IATA code - `departure_date` -- ISO 8601 date @@ -30,6 +31,7 @@ Queries distribution adapters in parallel, normalizes, deduplicates, filters, an - `sources?` -- specific adapter names to query **Output (`AvailabilitySearchOutput`):** + - `offers` -- deduplicated, filtered, sorted `SearchOffer[]` - `total_raw_offers` -- count before deduplication - `source_status` -- per-adapter query status (success, count, error, response time) @@ -48,6 +50,7 @@ Queries distribution adapters in parallel, normalizes, deduplicates, filters, an Flight schedule lookup with SSIM operating day parsing, codeshare detection, and connection discovery. **Input (`ScheduleLookupInput`):** + - `origin` -- airport IATA code - `destination` -- airport IATA code - `date` -- ISO 8601 date @@ -57,6 +60,7 @@ Flight schedule lookup with SSIM operating day parsing, codeshare detection, and - `include_connections?` -- discover connecting options (default: false) **Output (`ScheduleLookupOutput`):** + - `flights` -- `ScheduledFlight[]` (carrier, flight number, times, duration, schedule, codeshare info) - `connections` -- `ConnectionOption[]` (two-leg connections with timing) - `operates_on_date` -- whether any flights operate on the requested date @@ -69,9 +73,12 @@ Flight schedule lookup with SSIM operating day parsing, codeshare detection, and **Class:** `ConnectionBuilder` **Status:** Implemented -Validates connections against MCT (Minimum Connection Time) rules, scores connection quality, and checks interline agreements. +Validates connections against MCT (Minimum Connecting Time) rules, scores connection quality, and checks interline agreements. + +MCT authority: **IATA SSIM Chapter 8** + **PSC Resolution 765** (`docs/knowledge-base/mct.md`). Curated rows live under `data/reference/mct/`. Resolution hierarchy: carrier override → airport+terminal → airport → **fail-closed** (no invented global/airport-constant table). **Input (`ConnectionBuilderInput`):** + - `arriving_segment` -- `FlightSegment` (from `@otaip/core`) - `departing_segment` -- `FlightSegment` - `connection_airport` -- IATA 3-letter code @@ -79,7 +86,8 @@ Validates connections against MCT (Minimum Connection Time) rules, scores connec - `is_interline?` -- different carriers **Output (`ConnectionBuilderOutput`):** -- `validation` -- MCT check result (valid, available/required minutes, buffer, applied rule) + +- `validation` -- MCT check result (valid, available/required minutes or null when unavailable, buffer, applied rule) - `quality` -- connection quality score 0-1 with factor breakdown - `interline` -- interline agreement check (if different carriers) - `warnings` -- connection warnings @@ -95,12 +103,14 @@ Validates connections against MCT (Minimum Connection Time) rules, scores connec Multi-source fare comparison with fare basis decoding, class mapping, branded fare family grouping, and passenger type pricing. **Input (`FareShoppingInput`):** + - `origin`, `destination`, `departure_date`, `passengers` -- same as availability search - `cabin_class?`, `currency?`, `sources?` -- filters - `decode_fare_basis?` -- decode fare basis codes (default: true) - `group_by_fare_family?` -- group by basic/standard/flex/premium (default: true) **Output (`FareShoppingOutput`):** + - `fares` -- `FareOffer[]` sorted by price, each with decoded fare basis, class info, fare family, passenger pricing - `fare_families` -- grouped by family with cheapest/most expensive - `total_fares` -- count @@ -119,12 +129,14 @@ Multi-source fare comparison with fare basis decoding, class mapping, branded fa Searches for available ancillaries (baggage, seats, meals, lounge, Wi-Fi, priority) via an injectable adapter. **Input (`AncillaryShoppingInput`):** + - `segments` -- flight segments (origin, destination, flight number, date, carrier) - `passengers` -- passenger references with type (ADT/CHD/INF) - `pnrRef?` -- PNR reference - `requestedCategories?` -- filter by category (BAGGAGE, SEAT, MEAL, etc.) **Output (`AncillaryShoppingOutput`):** + - `ancillaries` -- `AncillaryOffer[]` with RFIC/RFISC codes, pricing, availability - `notSupportedByAdapter` -- true if no adapter configured - `currency` -- pricing currency @@ -140,12 +152,14 @@ Searches for available ancillaries (baggage, seats, meals, lounge, Wi-Fi, priori Aggregates search results from multiple adapters with deduplication, price comparison, and ranking. **Input (`MultiSourceInput`):** + - `results` -- `AdapterSearchResult[]` (adapter name, flights, errors, response time) - `deduplicationStrategy` -- `'keep_cheapest' | 'keep_all' | 'keep_first'` - `rankBy` -- `'price' | 'duration' | 'stops'` - `maxResults?` -- limit output count **Output (`MultiSourceOutput`):** + - `flights` -- `NormalizedFlight[]` with sources, lowest price, all prices across adapters - `totalRaw` -- raw count before dedup - `totalAfterDedup` -- final count @@ -164,11 +178,13 @@ Car rental search (and, pending a deferred car-only migration, hotel) via plugga **Routing ownership:** hotel search intent is owned by Hotel Search Aggregator (20.1) for all hotel queries -- standalone or within a trip. 1.7 owns car rental; it may compose with 20.1 for a combined hotel+car trip but does not own hotel intent. Route hotel queries to 20.1. **Input (`HotelCarSearchInput`):** + - `operation` -- `'searchHotels' | 'searchCars'` - `hotel?` -- hotel search params (destination, dates, rooms, adults, star rating, max rate) - `car?` -- car search params (pickup/dropoff location and times, category, driver age) **Output (`HotelCarSearchOutput`):** + - `hotelResults?` -- hotel offers with rate, room type, cancellation policy - `carResults?` -- car offers with category, supplier, daily/total rate, features @@ -183,10 +199,12 @@ Car rental search (and, pending a deferred car-only migration, hotel) via plugga Natural language travel query understanding with injectable LLM provider. Parses user queries into structured search parameters. **Input (`TravelAdvisorInput`):** + - `query` -- natural language travel query - `travelerContext?` -- preferences (cabin, budget, preferred airlines, passenger counts) **Output (`TravelAdvisorOutput`):** + - `searchParameters` -- extracted origin, destination, dates, trip type, cabin, passengers, flexible dates - `summary` -- natural language summary of interpreted query - `intent` -- `'flight_search' | 'hotel_search' | 'destination_recommendation' | 'price_check' | 'trip_planning' | 'unknown'` @@ -205,10 +223,12 @@ Natural language travel query understanding with injectable LLM provider. Parses Deterministic offer evaluation engine. Scores and ranks flight offers based on traveler profile, constraints, and configurable scoring weights. **Input (`OfferEvaluatorRequest`):** + - `offers` -- `EvaluatorOffer[]` with price, itinerary (segments, duration, connections) - `constraints?` -- latest arrival, prefer direct, max connections, price ceiling, preferred/blacklisted carriers - `profile?` -- `'BUSINESS_TIME_CRITICAL' | 'BUSINESS_PRICE_CONSTRAINED' | 'LEISURE' | 'CORPORATE_POLICY' | 'CUSTOM'` - `weights?` -- custom scoring weights (time_buffer, price, connection_quality, journey_duration) **Output (`OfferEvaluatorResponse`):** + - Scored and ranked offers with structured explanation for LLM translation diff --git a/docs/knowledge-base/mct.md b/docs/knowledge-base/mct.md new file mode 100644 index 0000000..23cab43 --- /dev/null +++ b/docs/knowledge-base/mct.md @@ -0,0 +1,53 @@ +# Minimum Connecting Time (MCT) — Domain Knowledge + +Authoritative sources for Connection Builder (Agent 1.3): + +- **IATA SSIM Chapter 8** — Presentation, Application and Transfer of Minimum Connecting Time (MCT) data +- **IATA PSC Resolution 765** — administration of MCTs; must be observed by ticketing and reservations outlets and automated reservations systems +- **IATA Minimum Connecting Time User Guide v1.1** — carrier filing / exception process and Record Type 2 mapping + https://www.iata.org/contentassets/638f0938b3dd451b872a1d8357755421/minimum-connecting-time-user-guide_version-1.1.pdf + +Do **not** invent MCT minutes from “industry folklore,” airport-constant tables, great-circle/haversine distance, or a fake global default table. Real MCT is **carrier-filed** (via data aggregators such as OAG/Cirium), optionally with airport/terminal specificity, and distributed under SSIM Ch.8. + +## Definition (passenger) + +Shortest time interval required to transfer a passenger and luggage from one flight to a connecting flight at a specific location or metropolitan area (SSIM Ch.8 / PSC Res 765 context in the User Guide). + +## Connection status codes (SSIM) + +| Code | Meaning | +| ---- | ----------------------------- | +| `DD` | Domestic → Domestic | +| `DI` | Domestic → International | +| `ID` | International → Domestic | +| `II` | International → International | + +## Resolution hierarchy (most specific wins) + +Application order for this platform (aligned with the issue #141 sketch and SSIM “most specific filing wins” practice). Template field order in the carrier submission form is **not** the same as hierarchy priority (User Guide §III). + +1. **Carrier override** — arriving carrier + departing carrier (+ optional flight ranges, equipment, codeshare indicators) at the connection airport, for a connection status (`DD`/`DI`/`ID`/`II`), optionally scoped to terminals. +2. **Airport + terminal** — station MCT when a terminal change (or explicit arrival/departure terminal pair) is known. +3. **Airport** — station-level MCT for the connection status when no carrier-pair row matches. +4. **Fail-closed** — if no curated row matches, **do not invent** an IATA global constant. Treat MCT as unavailable and reject the connection for shopping/validation. + +Carrier overrides are **required** for trustworthy online builds at hubs. Airport-level rows are only added when taken from a real SSIM/aggregator extract or another cited public source — never as guessed “hub constants.” + +## Online vs interline + +- **Online** — same marketing/operating carrier context (same carrier pair in the override, typically `arriving_carrier === departing_carrier`). +- **Interline** — different carriers. SSIM filings often need **concurrence** of the receiving carrier for exceptions (User Guide §II). Alliance membership alone does **not** prove an interline MCT exists. + +Starter dataset rows are online-only unless an interline row is explicitly checked in with a source citation. + +## Starter dataset location + +`data/reference/mct/` — curated JSON only. Incomplete by design. Prefer a few validated hub/carrier rows over a fake global table. + +## Open DOMAIN_QUESTIONs + +1. **DQ-MCT-1 (online vs interline MCT application):** For a given carrier pair at a station, when both online and interline filings exist (or only airport-level filings), which exact SSIM match order and concurrence rules does OTAIP use in production shopping? Unpublished carrier exceptions must not be invented. +2. **DQ-MCT-2 (DI vs ID / mixed):** How should Agent 1.3 map “mixed” domestic↔international legs to `DI` vs `ID` when country-of-airport data is incomplete? +3. **DQ-MCT-3 (codeshare):** When marketing carriers differ but the operating carrier is the same, do we apply operating-carrier MCT (User Guide examples on operating carrier / codeshare indicator) by default? +4. **DQ-MCT-4 (ingestion):** How will full SSIM Ch.8 / aggregator MCT feeds be ingested into `data/reference/mct/` (OAG vs Cirium, refresh cadence, suppression records)? +5. **DQ-MCT-5 (bags / special passenger):** `has_checked_bags` and other passenger conditions — which optional SSIM MCT elements apply, and from which filing? diff --git a/packages/agents/search/src/connection-builder/__tests__/connection-builder.test.ts b/packages/agents/search/src/connection-builder/__tests__/connection-builder.test.ts index 390ac3f..6e5b5fa 100644 --- a/packages/agents/search/src/connection-builder/__tests__/connection-builder.test.ts +++ b/packages/agents/search/src/connection-builder/__tests__/connection-builder.test.ts @@ -2,10 +2,11 @@ * Connection Builder — Unit Tests * * Agent 1.3: MCT validation, connection quality scoring, interline checking. + * MCT authority: SSIM Ch.8 + PSC Res 765 — fail-closed when no curated row. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ConnectionBuilder } from '../index.js'; +import { ConnectionBuilder, resolveMct, getMctDataDir } from '../index.js'; import type { FlightSegment } from '@otaip/core'; let agent: ConnectionBuilder; @@ -37,6 +38,12 @@ function makeSegment( } describe('Connection Builder', () => { + describe('MCT dataset', () => { + it('loads curated dataset from data/reference/mct/', () => { + expect(getMctDataDir()).toMatch(/data[/\\]reference[/\\]mct$/); + }); + }); + describe('MCT validation — valid connections', () => { it('validates a valid domestic connection at ORD (UA→UA)', async () => { const result = await agent.execute({ @@ -61,12 +68,12 @@ describe('Connection Builder', () => { expect(result.data.validation.valid).toBe(true); expect(result.data.validation.available_minutes).toBe(90); - // UA→UA at ORD domestic = 50 minutes (carrier-specific) + // UA→UA at ORD domestic = 50 minutes (carrier-specific curated row) expect(result.data.validation.required_mct_minutes).toBe(50); expect(result.data.validation.buffer_minutes).toBe(40); }); - it('validates a valid connection at ATL (short MCT)', async () => { + it('fail-closes when no curated MCT row exists (ATL DL→DL)', async () => { const result = await agent.execute({ data: { arriving_segment: makeSegment({ @@ -83,37 +90,60 @@ describe('Connection Builder', () => { }, }); - // ATL domestic default = 45 min, available = 60 min - expect(result.data.validation.valid).toBe(true); - expect(result.data.validation.required_mct_minutes).toBe(45); + // No invented ATL airport constant — fail-closed + expect(result.data.validation.valid).toBe(false); + expect(result.data.validation.required_mct_minutes).toBeNull(); + expect(result.data.validation.applied_rule).toContain('mct-unavailable'); + expect(result.data.warnings.some((w) => w.includes('fail-closed'))).toBe(true); }); }); describe('MCT validation — invalid connections', () => { - it('flags connection below MCT', async () => { + it('flags connection below carrier MCT (UA→UA ORD)', async () => { + // Carrier MCT = 50; available = 30 → below MCT const result = await agent.execute({ data: { arriving_segment: makeSegment({ - carrier: 'AA', - arrival_time: '2025-06-15T10:00:00-04:00', + carrier: 'UA', + arrival_time: '2025-06-15T10:00:00-05:00', departure_time: '2025-06-15T08:00:00-04:00', }), departing_segment: makeSegment({ - carrier: 'AA', - departure_time: '2025-06-15T10:30:00-04:00', + carrier: 'UA', + departure_time: '2025-06-15T10:30:00-05:00', arrival_time: '2025-06-15T13:00:00-07:00', }), - connection_airport: 'JFK', + connection_airport: 'ORD', }, }); - // JFK domestic default = 75 min, available = 30 min expect(result.data.validation.valid).toBe(false); expect(result.data.validation.available_minutes).toBe(30); + expect(result.data.validation.required_mct_minutes).toBe(50); expect(result.data.validation.buffer_minutes).toBeLessThan(0); }); - it('includes warning for invalid connection', async () => { + it('includes warning for connection below MCT', async () => { + const result = await agent.execute({ + data: { + arriving_segment: makeSegment({ + carrier: 'UA', + arrival_time: '2025-06-15T10:00:00-05:00', + departure_time: '2025-06-15T08:00:00-04:00', + }), + departing_segment: makeSegment({ + carrier: 'UA', + departure_time: '2025-06-15T10:30:00-05:00', + arrival_time: '2025-06-15T13:00:00-07:00', + }), + connection_airport: 'ORD', + }, + }); + + expect(result.data.warnings.some((w) => w.includes('below MCT'))).toBe(true); + }); + + it('fail-closes (invalid) when no curated row — does not invent JFK airport MCT', async () => { const result = await agent.execute({ data: { arriving_segment: makeSegment({ @@ -130,7 +160,9 @@ describe('Connection Builder', () => { }, }); - expect(result.data.warnings.some((w) => w.includes('below MCT'))).toBe(true); + expect(result.data.validation.valid).toBe(false); + expect(result.data.validation.required_mct_minutes).toBeNull(); + expect(result.data.validation.applied_rule).toContain('mct-unavailable'); }); }); @@ -154,9 +186,11 @@ describe('Connection Builder', () => { expect(result.data.validation.applied_rule).toContain('carrier-specific'); expect(result.data.validation.required_mct_minutes).toBe(50); + expect(result.metadata!['mct_level']).toBe('carrier_override'); }); - it('uses airport default MCT for DL→DL at ORD (no carrier-specific rule)', async () => { + it('prefers carrier-specific row over airport-level absence (DL→DL at ORD fail-closed)', async () => { + // No DL@ORD carrier row and no airport-rules → fail-closed (not a fake airport default) const result = await agent.execute({ data: { arriving_segment: makeSegment({ @@ -173,11 +207,13 @@ describe('Connection Builder', () => { }, }); - expect(result.data.validation.applied_rule).toContain('airport default'); - expect(result.data.validation.required_mct_minutes).toBe(60); + expect(result.data.validation.applied_rule).toContain('mct-unavailable'); + expect(result.data.validation.required_mct_minutes).toBeNull(); + expect(result.data.validation.valid).toBe(false); + expect(result.metadata!['mct_level']).toBe('unavailable'); }); - it('falls back to IATA default for unknown airport', async () => { + it('fail-closes for unknown airport — no invented IATA global default', async () => { const result = await agent.execute({ data: { arriving_segment: makeSegment({ @@ -194,8 +230,21 @@ describe('Connection Builder', () => { }, }); - expect(result.data.validation.applied_rule).toContain('IATA default'); - expect(result.data.validation.required_mct_minutes).toBe(60); + expect(result.data.validation.applied_rule).toContain('mct-unavailable'); + expect(result.data.validation.required_mct_minutes).toBeNull(); + expect(result.data.validation.valid).toBe(false); + }); + + it('resolveMct: carrier override beats empty airport table', () => { + const carrier = resolveMct('ORD', 'domestic', 'unknown', 'UA', 'UA'); + expect(carrier.resolved).toBe(true); + expect(carrier.level).toBe('carrier_override'); + expect(carrier.minutes).toBe(50); + + const other = resolveMct('ORD', 'domestic', 'unknown', 'DL', 'DL'); + expect(other.resolved).toBe(false); + expect(other.level).toBe('unavailable'); + expect(other.minutes).toBeNull(); }); }); @@ -221,7 +270,7 @@ describe('Connection Builder', () => { expect(result.data.quality.factors.length).toBeGreaterThan(0); }); - it('scores an invalid connection as 0 for time factor', async () => { + it('scores fail-closed MCT as 0 for time factor', async () => { const result = await agent.execute({ data: { arriving_segment: makeSegment({ @@ -241,6 +290,7 @@ describe('Connection Builder', () => { const timeFactor = result.data.quality.factors.find((f) => f.name === 'connection_time'); expect(timeFactor).toBeDefined(); expect(timeFactor!.score).toBe(0); + expect(timeFactor!.description).toContain('fail-closed'); }); it('warns about very long connections', async () => { @@ -329,6 +379,9 @@ describe('Connection Builder', () => { expect(result.data.interline!.same_alliance).toBe(true); expect(result.data.interline!.alliance).toBe('star_alliance'); expect(result.data.interline!.interline_allowed).toBe(true); + // No interline MCT row curated → fail-closed (DQ-MCT-1) + expect(result.data.validation.applied_rule).toContain('mct-unavailable'); + expect(result.data.validation.valid).toBe(false); }); it('detects different-alliance interline', async () => { diff --git a/packages/agents/search/src/connection-builder/connection-scorer.ts b/packages/agents/search/src/connection-builder/connection-scorer.ts index aeb7254..6e80626 100644 --- a/packages/agents/search/src/connection-builder/connection-scorer.ts +++ b/packages/agents/search/src/connection-builder/connection-scorer.ts @@ -12,7 +12,8 @@ import type { QualityFactor } from './types.js'; export interface ScoringInput { availableMinutes: number; - requiredMctMinutes: number; + /** Null when MCT unresolved (fail-closed) — connection_time scores 0 */ + requiredMctMinutes: number | null; sameCarrier: boolean; sameAlliance: boolean; terminalChange: boolean; @@ -59,14 +60,17 @@ export function scoreConnection(input: ScoringInput): { const factors: QualityFactor[] = []; // Factor 1: Connection time (weight: 0.4) - const timeScore = scoreConnectionTime(input.availableMinutes, input.requiredMctMinutes); + const mct = input.requiredMctMinutes; + const timeScore = mct === null ? 0 : scoreConnectionTime(input.availableMinutes, mct); factors.push({ name: 'connection_time', score: timeScore, description: - timeScore === 0 - ? `${input.availableMinutes}min < ${input.requiredMctMinutes}min MCT - illegal connection` - : `${input.availableMinutes}min available, ${input.requiredMctMinutes}min required`, + mct === null + ? 'MCT unavailable (fail-closed) — no curated SSIM row' + : timeScore === 0 + ? `${input.availableMinutes}min < ${mct}min MCT - illegal connection` + : `${input.availableMinutes}min available, ${mct}min required`, }); // Factor 2: Same carrier (weight: 0.25) diff --git a/packages/agents/search/src/connection-builder/index.ts b/packages/agents/search/src/connection-builder/index.ts index 40d6462..5e17a39 100644 --- a/packages/agents/search/src/connection-builder/index.ts +++ b/packages/agents/search/src/connection-builder/index.ts @@ -4,6 +4,9 @@ * Validates connections against MCT rules, scores connection quality, * and checks interline agreements. * + * MCT authority: IATA SSIM Chapter 8 + PSC Resolution 765 + * (see docs/knowledge-base/mct.md). Missing curated rows fail closed. + * * Implements the base Agent interface from @otaip/core. */ @@ -46,7 +49,8 @@ export class ConnectionBuilder implements Agent= mct.minutes; - const bufferMinutes = availableMinutes - mct.minutes; + const valid = mct.resolved && mct.minutes !== null && availableMinutes >= mct.minutes; + const bufferMinutes = + mct.resolved && mct.minutes !== null ? availableMinutes - mct.minutes : null; const validation = { valid, @@ -102,12 +107,16 @@ export class ConnectionBuilder implements Agent= 0 && bufferMinutes < 15) { + if (bufferMinutes !== null && bufferMinutes >= 0 && bufferMinutes < 15) { warnings.push('Very tight connection — less than 15 minutes buffer over MCT.'); } if (availableMinutes > 360) { @@ -131,6 +140,7 @@ export class ConnectionBuilder implements Agent = { - domestic: 60, - international: 90, - mixed: 90, -}; +export interface MctResolution { + /** Minutes when resolved; null when fail-closed (no curated row). */ + minutes: number | null; + /** Human-readable applied rule label */ + rule: string; + /** Hierarchy level that produced the result */ + level: MctHierarchyLevel; + /** False → fail-closed; caller must not invent a substitute MCT */ + resolved: boolean; +} + +interface CarrierOverrideRow { + airport: string; + arriving_carrier: string; + departing_carrier: string; + connection_status?: string; + connection_type: ConnectionType; + scope?: 'online' | 'interline'; + minutes: number; + arrival_terminal?: string | null; + departure_terminal?: string | null; +} + +interface AirportRuleRow { + airport: string; + connection_type: ConnectionType; + connection_status?: string; + minutes: number; + /** When true, row applies only when terminal change is `different`. */ + terminal_change?: boolean; + arrival_terminal?: string | null; + departure_terminal?: string | null; +} + +interface CarrierOverridesFile { + overrides: CarrierOverrideRow[]; +} + +interface AirportRulesFile { + rules: AirportRuleRow[]; +} // --------------------------------------------------------------------------- -// Airport-specific MCT rules (top 20 hubs) +// Dataset load (curated JSON under data/reference/mct/) // --------------------------------------------------------------------------- -interface AirportMctConfig { - default_domestic: number; - default_international: number; - terminal_change_domestic?: number; - terminal_change_international?: number; - carrier_rules?: { - arriving?: string; - departing?: string; - connection_type: ConnectionType; - minutes: number; - }[]; +function candidateMctDirs(): string[] { + const dirs: string[] = []; + const cwdDir = join(process.cwd(), 'data', 'reference', 'mct'); + dirs.push(cwdDir); + + // When tests or tools run from a package cwd, walk up looking for the monorepo dataset. + try { + const here = dirname(fileURLToPath(import.meta.url)); + let cursor = here; + for (let i = 0; i < 8; i++) { + dirs.push(join(cursor, 'data', 'reference', 'mct')); + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + } catch { + // import.meta.url unavailable in unlikely hosts — cwd candidate remains. + } + + return dirs; } -const AIRPORT_MCT: Record = { - JFK: { - default_domestic: 75, - default_international: 120, - terminal_change_domestic: 120, - terminal_change_international: 150, - }, - LAX: { - default_domestic: 75, - default_international: 120, - terminal_change_domestic: 120, - terminal_change_international: 150, - }, - ORD: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 90, - terminal_change_international: 120, - carrier_rules: [{ arriving: 'UA', departing: 'UA', connection_type: 'domestic', minutes: 50 }], - }, - ATL: { - default_domestic: 45, - default_international: 90, - }, - DFW: { - default_domestic: 60, - default_international: 90, - }, - DEN: { - default_domestic: 55, - default_international: 90, - }, - SFO: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 90, - terminal_change_international: 120, - }, - LHR: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 120, - terminal_change_international: 150, - }, - CDG: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 120, - terminal_change_international: 150, - }, - FRA: { - default_domestic: 45, - default_international: 60, - }, - AMS: { - default_domestic: 40, - default_international: 50, - }, - NRT: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 90, - terminal_change_international: 120, - }, - HND: { - default_domestic: 60, - default_international: 90, - terminal_change_domestic: 120, - terminal_change_international: 150, - }, - DXB: { - default_domestic: 60, - default_international: 90, - }, - SIN: { - default_domestic: 45, - default_international: 60, - }, - HKG: { - default_domestic: 45, - default_international: 75, - }, - ICN: { - default_domestic: 60, - default_international: 90, - }, - IST: { - default_domestic: 60, - default_international: 90, - }, - DOH: { - default_domestic: 45, - default_international: 60, - }, - MIA: { - default_domestic: 60, - default_international: 90, - }, -}; +function readJsonIfPresent(path: string): T | null { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, 'utf-8')) as T; +} + +function loadDataset(): { + carrierOverrides: CarrierOverrideRow[]; + airportRules: AirportRuleRow[]; + dataDir: string | null; +} { + for (const dir of candidateMctDirs()) { + const carrierPath = join(dir, 'carrier-overrides.json'); + const airportPath = join(dir, 'airport-rules.json'); + if (!existsSync(carrierPath)) continue; + + const carriers = readJsonIfPresent(carrierPath); + const airports = readJsonIfPresent(airportPath); + return { + carrierOverrides: carriers?.overrides ?? [], + airportRules: airports?.rules ?? [], + dataDir: dir, + }; + } + + // No dataset on disk → empty tables → every resolve is fail-closed. + return { carrierOverrides: [], airportRules: [], dataDir: null }; +} + +const DATASET = loadDataset(); + +/** Test / tooling hook to inspect which directory was loaded. */ +export function getMctDataDir(): string | null { + return DATASET.dataDir; +} // --------------------------------------------------------------------------- -// MCT resolution (4-level hierarchy) +// MCT resolution // --------------------------------------------------------------------------- +function carriersMatch( + row: CarrierOverrideRow, + arrivingCarrier: string, + departingCarrier: string, +): boolean { + return ( + row.arriving_carrier.toUpperCase() === arrivingCarrier.toUpperCase() && + row.departing_carrier.toUpperCase() === departingCarrier.toUpperCase() + ); +} + +function scopeAllows( + row: CarrierOverrideRow, + arrivingCarrier: string, + departingCarrier: string, +): boolean { + const online = arrivingCarrier.toUpperCase() === departingCarrier.toUpperCase(); + if (row.scope === 'online' && !online) return false; + if (row.scope === 'interline' && online) return false; + return true; +} + +/** + * Resolve MCT for a connection. + * Returns resolved=false (fail-closed) when no curated row matches. + * Never invents airport constants or a global default table. + */ export function resolveMct( airport: string, connectionType: ConnectionType, terminalChange: TerminalChangeType, arrivingCarrier?: string, departingCarrier?: string, -): { minutes: number; rule: string } { - const config = AIRPORT_MCT[airport]; +): MctResolution { + const station = airport.toUpperCase().trim(); // Level 1: Carrier-specific at airport - if (config?.carrier_rules && arrivingCarrier && departingCarrier) { - for (const rule of config.carrier_rules) { - if ( - rule.arriving === arrivingCarrier && - rule.departing === departingCarrier && - rule.connection_type === connectionType - ) { - return { - minutes: rule.minutes, - rule: `carrier-specific: ${arrivingCarrier}→${departingCarrier} at ${airport}`, - }; - } + if (arrivingCarrier && departingCarrier) { + for (const row of DATASET.carrierOverrides) { + if (row.airport.toUpperCase() !== station) continue; + if (row.connection_type !== connectionType) continue; + if (!carriersMatch(row, arrivingCarrier, departingCarrier)) continue; + if (!scopeAllows(row, arrivingCarrier, departingCarrier)) continue; + + // TODO: DOMAIN_QUESTION: DQ-MCT-3 — when override lists arrival/departure + // terminals (or codeshare operating carrier), exact match rules vs unknown terminals. + return { + minutes: row.minutes, + rule: `carrier-specific: ${arrivingCarrier}→${departingCarrier} at ${station}`, + level: 'carrier_override', + resolved: true, + }; } + + // // TODO: DOMAIN_QUESTION: DQ-MCT-1 — unpublished interline / concurrence exceptions + // // must not be synthesized when carriers differ and no interline row exists. } // Level 2: Airport + connection type + terminal change - if (config && terminalChange === 'different') { - const tcMinutes = - connectionType === 'domestic' - ? config.terminal_change_domestic - : config.terminal_change_international; - - if (tcMinutes !== undefined) { + if (terminalChange === 'different') { + for (const row of DATASET.airportRules) { + if (row.airport.toUpperCase() !== station) continue; + if (row.connection_type !== connectionType) continue; + if (row.terminal_change !== true) continue; return { - minutes: tcMinutes, - rule: `airport terminal-change: ${airport} ${connectionType}`, + minutes: row.minutes, + rule: `airport terminal-change: ${station} ${connectionType}`, + level: 'airport_terminal', + resolved: true, }; } } - // Level 3: Airport + connection type - if (config) { - const minutes = - connectionType === 'domestic' ? config.default_domestic : config.default_international; + // Level 3: Airport + connection type (no terminal-change requirement) + for (const row of DATASET.airportRules) { + if (row.airport.toUpperCase() !== station) continue; + if (row.connection_type !== connectionType) continue; + if (row.terminal_change === true) continue; return { - minutes, - rule: `airport default: ${airport} ${connectionType}`, + minutes: row.minutes, + rule: `airport default: ${station} ${connectionType}`, + level: 'airport', + resolved: true, }; } - // Level 4: IATA global defaults + // Level 4: Fail-closed — do not invent a global MCT table or airport constants. return { - minutes: IATA_DEFAULTS[connectionType], - rule: `IATA default: ${connectionType}`, + minutes: null, + rule: + `mct-unavailable: no curated row for ${station} ${connectionType}` + + (arrivingCarrier && departingCarrier ? ` (${arrivingCarrier}→${departingCarrier})` : ''), + level: 'unavailable', + resolved: false, }; } // --------------------------------------------------------------------------- -// Alliance data (for interline checks) +// Alliance data (for interline checks — agreement existence, not MCT minutes) // --------------------------------------------------------------------------- const ALLIANCE_MAP: Record = { @@ -248,6 +277,11 @@ const ALLIANCE_MAP: Record = { VN: 'skyteam', }; +/** + * Alliance / rough interline allowance check. + * // TODO: DOMAIN_QUESTION: Real interline agreement database (distinct from MCT filings). + * // Alliance membership ≠ MCT concurrence (SSIM User Guide §II). + */ export function checkInterline( carrier1: string, carrier2: string, @@ -263,8 +297,6 @@ export function checkInterline( return { interlineAllowed: true, sameAlliance: true, alliance: alliance1 }; } - // For now, assume all major carriers have interline agreements - // TODO: [NEEDS DOMAIN INPUT] Real interline agreement database const hasAnyAlliance = !!(alliance1 || alliance2); return { interlineAllowed: hasAnyAlliance, diff --git a/packages/agents/search/src/connection-builder/types.ts b/packages/agents/search/src/connection-builder/types.ts index a4630f2..bce6503 100644 --- a/packages/agents/search/src/connection-builder/types.ts +++ b/packages/agents/search/src/connection-builder/types.ts @@ -39,15 +39,18 @@ export interface MctRule { } export interface ConnectionValidation { - /** Whether the connection meets MCT requirements */ + /** Whether the connection meets MCT requirements (false when MCT unresolved / fail-closed) */ valid: boolean; /** Available connection time in minutes */ available_minutes: number; - /** Required MCT in minutes */ - required_mct_minutes: number; - /** Buffer time (available - required) */ - buffer_minutes: number; - /** MCT rule that was applied */ + /** + * Required MCT in minutes, or null when no curated MCT row matched (fail-closed). + * Never invent a substitute minutes value when null. + */ + required_mct_minutes: number | null; + /** Buffer time (available - required); null when MCT unresolved */ + buffer_minutes: number | null; + /** MCT rule that was applied (includes mct-unavailable:… when fail-closed) */ applied_rule: string; /** Connection type classification */ connection_type: ConnectionType; From d8f3de2e431de976e8a4521e8139da9ec0b73365 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 09:01:00 +0000 Subject: [PATCH 2/3] chore: regenerate docs/agent-map.html after stage-1 MCT docs pnpm gen:manifest --check failed because stage-1-search.md updates were not reflected in the committed agent map HTML. Co-authored-by: telivity-otaip --- docs/agent-map.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..6cbb992 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- + - +