From 2667ef8eda44a1f0022101bffab9ef672f8bdf91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 07:55:45 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(pricing/2.2):=20fail-closed=20fare=20c?= =?UTF-8?q?onstruction=20=E2=80=94=20IROE,=20TPM,=20024d=20HX/NX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens Agent 2.2 for #151: licensed data via data_sources, no bundled IROE/TPM files, bans on haversine-as-TPM and banker rounding, DomainInputRequired when IROE/TPM/024d missing. Adds KB data-dependencies doc and tests. Co-authored-by: telivity-otaip --- CLAUDE.md | 6 +- docs/agents/stage-2-pricing.md | 24 +- .../fare-construction-data-dependencies.md | 168 +++++ .../__tests__/fare-construction.test.ts | 666 +++++++----------- .../fixtures/test-fare-construction-data.json | 27 + .../fare-construction/data/mileage-data.json | 56 -- .../src/fare-construction/data/roe-rates.json | 27 - .../data/rounding-rules.json | 27 - .../src/fare-construction/fare-engine.ts | 368 ++++++---- .../pricing/src/fare-construction/index.ts | 37 +- .../pricing/src/fare-construction/types.ts | 169 +++-- packages/agents/pricing/src/index.ts | 18 +- .../src/tax-calculation/data/tax-rates.json | 2 +- .../pricing/src/tax-calculation/tax-engine.ts | 11 +- 14 files changed, 853 insertions(+), 753 deletions(-) create mode 100644 docs/knowledge-base/fare-construction-data-dependencies.md create mode 100644 packages/agents/pricing/src/fare-construction/__tests__/fixtures/test-fare-construction-data.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/mileage-data.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/roe-rates.json delete mode 100644 packages/agents/pricing/src/fare-construction/data/rounding-rules.json diff --git a/CLAUDE.md b/CLAUDE.md index 4d412f0..e6b7e76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,9 +107,9 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| -| "NUC conversion uses a standard multiply-and-round pattern, I'll apply banker's rounding" | STOP. IATA rounding rules are currency-specific and are NOT standard banker's rounding. Check KB for the IATA rounding table. Do not implement any rounding without the exact rule for the currency in question. | -| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. ROE values are published by IATA monthly. Hardcoded ROE values will produce wrong fares immediately. Surface as DOMAIN_QUESTION: how will ROE data be ingested? | -| "TPM/MPM mileage data isn't available, I'll approximate the distance using haversine between airports" | STOP. TPM is NOT great-circle distance. TPM is published by IATA/SITA and includes routing-specific values that differ from haversine. HIP/BHC/CTM checks depend on exact TPM. Do not approximate. Surface as DOMAIN_QUESTION. | +| "NUC conversion uses a standard multiply-and-round pattern, I'll apply banker's rounding" | STOP. IATA Resolution **024d** uses per-currency units with **HX** (round up) or **NX** (nearest). Banker's / half-to-even is not a substitute. Check KB `docs/knowledge-base/fare-construction-data-dependencies.md`. | +| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. **IROE** values are published by IATA. Hardcoded rates produce wrong fares immediately. Pass licensed rates via `data_sources.iroe` or surface DOMAIN_INPUT_REQUIRED. Do not commit proprietary IROE files. | +| "TPM/MPM mileage data isn't available, I'll approximate the distance using haversine between airports" | STOP. TPM is NOT great-circle distance. TPM is published by IATA ([TPM Manual](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/)). HIP/BHC/CTM depend on exact TPM. Fail closed — never approximate. | | "The HIP check logic seems straightforward — just compare the fare to the sum of sector fares" | STOP. HIP/BHC/CTM each have different comparison rules, directionality requirements, and NUC-vs-local-currency considerations. Check KB for the exact ATPCO comparison logic before implementing. | | "For multi-sector itineraries, I'll prorate the fare evenly across segments" | STOP. IATA fare proration uses TPM-based proportional allocation, not equal division. Check KB for the exact proration formula. | | "I don't have the fare construction rules for this specific routing, I'll use the published fare directly" | STOP. Published fares and constructed fares are different pricing mechanisms. Do not conflate them. Surface as DOMAIN_QUESTION: which fare type applies? | diff --git a/docs/agents/stage-2-pricing.md b/docs/agents/stage-2-pricing.md index c9f703b..0e2341a 100644 --- a/docs/agents/stage-2-pricing.md +++ b/docs/agents/stage-2-pricing.md @@ -36,24 +36,22 @@ Parses ATPCO fare rules (categories 1-20) into human-readable structured format **Class:** `FareConstruction` **Status:** Implemented -NUC x ROE fare construction with mileage validation, HIP/BHC/CTM checks, surcharges, and IATA rounding. All financial math uses `decimal.js`. +NUC × IROE construction with published TPM/MPM, Resolution **024d** (HX/NX) rounding, and fail-closed HIP/BHC/CTM hooks. All financial math uses `decimal.js`. + +Licensed IROE / 024d / TPM-MPM data are **not** shipped in the package. Pass them via `data_sources`. Missing IROE, TPM, or 024d → `DomainInputRequired`. Haversine-as-TPM and banker's rounding are banned. + +**KB:** `docs/knowledge-base/fare-construction-data-dependencies.md` **Input (`FareConstructionInput`):** - `journey_type` -- `'OW' | 'RT' | 'CT'` (one-way, round-trip, circle-trip) - `components` -- fare components (origin, destination, carrier, fare basis, NUC amount) - `selling_currency` -- ISO 4217 currency -- `point_of_sale?` -- country for ROE selection - -**Output (`FareConstructionOutput`):** -- `total_nuc` -- sum of components + surcharges -- `roe` -- ROE used for conversion -- `local_amount` -- final amount after IATA rounding -- `currency` -- selling currency -- `mileage_checks` -- per-component TPM/MPM validation -- `mileage_exceeded` -- whether total mileage exceeds MPM -- `mileage_surcharge` -- surcharge details if applicable -- `hip_check`, `bhc_check`, `ctm_check` -- mileage system checks -- `audit_trail` -- full calculation audit +- `point_of_sale?` -- country for IROE selection context +- `data_sources?` -- licensed `{ iroe, rounding_024d, mileage }` (required for a constructed fare) + +**Output (`FareConstructionResult`):** +- Success (`FareConstructionOutput`): `total_nuc`, `iroe` (alias `roe`), `local_amount` after 024d, `rounding_method` (`HX`|`NX`), published `mileage_checks`, HIP/BHC/CTM sketches, `audit_trail` +- Or `DomainInputRequired` when licensed data is missing --- diff --git a/docs/knowledge-base/fare-construction-data-dependencies.md b/docs/knowledge-base/fare-construction-data-dependencies.md new file mode 100644 index 0000000..7e61f69 --- /dev/null +++ b/docs/knowledge-base/fare-construction-data-dependencies.md @@ -0,0 +1,168 @@ +# Fare Construction — Data Dependencies (Agent 2.2) + +Authoritative contracts for NUC × IROE construction, published TPM/MPM, +and IATA Resolution **024d** currency rounding. This document describes +**what to ingest**, not the proprietary table contents. + +Do **not** commit IROE rates, 024d rounding tables, or TPM/MPM datasets to +this repository. Licensed feeds belong in deployment secrets / private +object storage and are passed into the agent at runtime via +`FareConstructionInput.data_sources`. + +--- + +## Formula (high level) + +1. Construct / sum fare components in **NUC** (Neutral Unit of Construction). +2. Convert total NUC → local currency of sale / COC using **IROE** + (IATA Rate of Exchange): `local = NUC × IROE`. +3. Apply **Resolution 024d** rounding (method **HX** or **NX**, per currency + unit published in the IROE / 024d table). +4. Mileage-system checks (HIP / BHC / CTM) and MPM excess surcharges require + **published TPM** (and MPM) — never great-circle / haversine substitutes. + +--- + +## Explicit bans + +| Ban | Why | +|---|---| +| **No hardcoded IROE** in production code or shipped packages | IROE is published periodically by IATA; hardcoded rates go stale immediately and produce wrong local-currency fares. | +| **No haversine / great-circle as TPM** | TPM is a published ticketed-point mileage from the IATA TPM Manual (non-stop / through scheduled services). It is **not** geodesic distance. HIP/BHC/CTM and MPM excess depend on exact published TPM. | +| **No banker's rounding as “IATA rounding”** | Resolution **024d** uses per-currency units with methods **HX** (round up to next higher unit) or **NX** (round to nearest unit). IEEE banker's / half-to-even rounding is not a substitute. | +| **No equal-sector proration as TPM proration** | IATA proration uses TPM-based allocation, not equal split. | +| **No inventing HIP / BHC / CTM comparison rules** | Those checks need filed intermediate fares / directionality / CT minima from ATPCO / carrier filings. Surface `DOMAIN_INPUT_REQUIRED` / `missing_inputs` instead. | + +--- + +## Data dependency 1 — IROE (IATA Rate of Exchange) + +| Field | Contract | +|---|---| +| **What** | Per-currency conversion factor: NUC → local currency (and inverse for published local → NUC). | +| **Source** | IATA Rates of Exchange (IROE) / Clearing House publication. Related: ICER (Consolidated Exchange Rates). | +| **Cadence** | Periodic IATA publication (subscribe / license; do not scrape or pirate). | +| **Ingestion shape** | `Record` plus optional `effective_date` / period metadata at the feed boundary. | +| **Fail-closed** | If the selling currency has no IROE entry → return `DomainInputRequired` with `missing: ['iroe_table_entry:{CCY}']`. **Never** fall back to `1.0`. | + +Contributor note: purchase / subscribe via IATA publications (IROE). Do not +vendor the rate file into git. + +--- + +## Data dependency 2 — Resolution 024d rounding table + +| Field | Contract | +|---|---| +| **What** | Per-currency **rounding unit** and **method**. | +| **Methods** | **HX** — round up to the next higher unit (unless already exact). **NX** — round to the nearest unit. | +| **Source** | IATA Resolution **024d** (Currency Names, Codes, Rounding Units…) as carried with IROE / Passenger Standards Conference Tariffs materials. | +| **Ingestion shape** | `Record`. | +| **Fail-closed** | If the selling currency has no 024d entry → `DomainInputRequired` with `missing: ['rounding_024d:{CCY}']`. **No** default `0.01` unit. **No** banker's rounding fallback. | + +The engine may implement the **mechanical** meaning of HX/NX once the +unit + method are supplied. It must **not** invent which currency uses +which unit or method. + +--- + +## Data dependency 3 — TPM / MPM + +| Field | Contract | +|---|---| +| **What** | **TPM** — Ticketed Point Mileage per coupon / city pair. **MPM** — Maximum Permitted Mileage for the fare component / routing. | +| **TPM definition** | Distance covered by one flight coupon, based on non-stop or through scheduled air services (IATA TPM Manual). Official source covers 65,000+ city pairs; updates monthly (`.txt` / API for system integration). | +| **Reference** | [IATA Ticketed Point Mileage (TPM)](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/) — also MPM Manual, City Code Directory (CCD). | +| **Ingestion shape** | City-pair rows `{ origin, destination, tpm, mpm }` from the licensed TPM/MPM feed (airport or city codes per CCD / multi-airport rules — do not invent). | +| **Fail-closed** | If any fare component lacks a published TPM (and MPM when excess checks run) → `DomainInputRequired` with `missing: ['tpm:{ORG}-{DST}']` (and/or `mpm:…`). **Never** approximate with haversine. | + +Contributor note: license the TPM Manual (and MPM Manual) from IATA. +Integrate via their `.txt` / API delivery. Do **not** commit proprietary +mileage tables to this repo. + +--- + +## Fail-closed policy (runtime) + +When any of IROE, 024d rounding, or TPM/MPM required for the itinerary is +absent: + +1. Halt fare construction. +2. Return `DomainInputRequired` (`status: 'DOMAIN_INPUT_REQUIRED'`) listing + machine-readable `missing` keys and references to the licensed sources. +3. Set agent `confidence` to `0` and surface warnings — do not emit a + local-currency amount. + +Silent approximation is a CLAUDE.md Agent 2.2 violation. + +--- + +## Minimal interface sketch — HIP / BHC / CTM + +These are **mileage-system** checks. Comparison rules are **not** +implemented here; callers / future work supply filed data and apply the +published ATPCO / IATA algorithms. + +```typescript +/** Inputs required before HIP can run — do not invent comparison rules. */ +interface HipCheckRequirements { + /** Filed NUC fares for every intermediate point pair on the routing. */ + intermediate_point_fares: Array<{ + origin: string; + destination: string; + carrier: string; + nuc_amount: string; + }>; + // TODO: DOMAIN_QUESTION: exact HIP comparison order / directionality / + // NUC-vs-local rules per ATPCO Fare Construction guide for this carrier. +} + +/** Inputs required before BHC can run. */ +interface BhcCheckRequirements { + /** Geographic / direction analysis for each fare component vs journey. */ + geographic_direction_analysis: unknown; + // TODO: DOMAIN_QUESTION: published BHC directionality rule set for this + // journey type — do not use "city revisited" string heuristics. +} + +/** Inputs required before CTM can run (circle trips). */ +interface CtmCheckRequirements { + /** Half round-trip / CT minimum fare amounts per component as filed. */ + circle_trip_minima_nuc: Array<{ component_index: number; ctm_nuc: string }>; + // TODO: DOMAIN_QUESTION: CTM measurement and comparison vs constructed + // total — carrier / ATPCO specific. +} +``` + +Until those inputs exist, Agent 2.2 reports `detected: false` / +`applies: false` with `missing_inputs` populated — it does **not** invent +HIP amounts, backhaul hits, or CT minima. + +--- + +## Licensed sources checklist (contributors) + +| Need | Where to obtain (buy / subscribe) | Commit to git? | +|---|---|---| +| IROE rates | IATA Rates of Exchange (IROE); related ICER | **No** | +| 024d units + HX/NX | Resolution 024d via IATA Passenger Standards / IROE materials | **No** | +| TPM city-pair mileages | [IATA TPM Manual](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/) (`.txt` / API) | **No** | +| MPM | IATA Maximum Permitted Mileage Manual | **No** | +| City / multi-airport codes | IATA City Code Directory (CCD) | **No** | +| HIP intermediate fares | ATPCO fare filings / carrier fare construction data | **No** | + +Test fixtures under +`packages/agents/pricing/src/fare-construction/__tests__/fixtures/` are +**invented** numbers for unit tests only. They must carry a +`TEST FIXTURE — do not use in production` banner and must never be loaded +by the production engine module graph. + +--- + +## Related code + +- `packages/agents/pricing/src/fare-construction/` — Agent 2.2 +- `@otaip/core` `DomainInputRequired` / `domainInputRequired` +- `CLAUDE.md` — Agent 2.2 anti-rationalization guards +- Tax calculation still has separate FX TODOs — do not reuse invented + tax `currency_conversions` as IROE diff --git a/packages/agents/pricing/src/fare-construction/__tests__/fare-construction.test.ts b/packages/agents/pricing/src/fare-construction/__tests__/fare-construction.test.ts index 320954a..8c8d01e 100644 --- a/packages/agents/pricing/src/fare-construction/__tests__/fare-construction.test.ts +++ b/packages/agents/pricing/src/fare-construction/__tests__/fare-construction.test.ts @@ -1,13 +1,62 @@ /** * Fare Construction — Unit Tests * - * Agent 2.2: NUC × ROE, mileage validation, HIP, BHC, CTM, surcharges, IATA rounding. - * All amounts verified with decimal.js precision. + * Agent 2.2: NUC × IROE, published TPM/MPM, 024d HX/NX, fail-closed hooks. + * Fixtures are invented test numbers — not production IROE/TPM. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { FareConstruction } from '../index.js'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Decimal } from 'decimal.js'; +import { + FareConstruction, + constructFare, + apply024dRounding, + assertPublishedTpmSource, + HAVERSINE_AS_TPM_BANNED, + HARDCODED_IROE_BANNED, + BANKERS_ROUNDING_AS_IATA_BANNED, +} from '../index.js'; +import type { + FareConstructionDataSources, + FareConstructionInput, + Rounding024dRule, +} from '../types.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = JSON.parse( + readFileSync(resolve(__dirname, 'fixtures/test-fare-construction-data.json'), 'utf8'), +) as { + iroe: Record; + rounding_024d: Record; + mileage: FareConstructionDataSources['mileage']; +}; + +const TEST_SOURCES: FareConstructionDataSources = { + iroe: FIXTURE.iroe, + rounding_024d: FIXTURE.rounding_024d, + mileage: FIXTURE.mileage, +}; + +function makeInput(overrides: Partial = {}): FareConstructionInput { + return { + journey_type: 'OW', + components: [ + { + origin: 'JFK', + destination: 'LHR', + carrier: 'UA', + fare_basis: 'V14NR', + nuc_amount: '450.00', + }, + ], + selling_currency: 'USD', + data_sources: TEST_SOURCES, + ...overrides, + }; +} let agent: FareConstruction; @@ -21,35 +70,134 @@ afterAll(() => { }); describe('Fare Construction', () => { - describe('One-way (OW) fare construction', () => { - it('constructs JFK-LHR OW fare in USD', async () => { + describe('Fail-closed: missing licensed data', () => { + it('returns DOMAIN_INPUT_REQUIRED when data_sources omitted (no hardcoded IROE)', async () => { + const result = await agent.execute({ + data: makeInput({ data_sources: undefined }), + }); + + expect('status' in result.data && result.data.status).toBe('DOMAIN_INPUT_REQUIRED'); + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + expect(result.data.missing).toEqual( + expect.arrayContaining([ + 'data_sources.iroe', + 'data_sources.rounding_024d', + 'data_sources.mileage', + ]), + ); + expect(result.data.description).toContain('No hardcoded'); + } + expect(result.confidence).toBe(0); + }); + + it('returns DOMAIN_INPUT_REQUIRED for missing IROE currency (no 1.0 fallback)', async () => { + const result = await agent.execute({ + data: makeInput({ selling_currency: 'XYZ' }), + }); + + expect('status' in result.data && result.data.status).toBe('DOMAIN_INPUT_REQUIRED'); + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + expect(result.data.missing).toContain('iroe_table_entry:XYZ'); + expect(result.data.description).toContain('IROE'); + } + expect(result.confidence).toBe(0); + }); + + it('returns DOMAIN_INPUT_REQUIRED when published TPM is missing (no haversine)', async () => { const result = await agent.execute({ - data: { - journey_type: 'OW', + data: makeInput({ components: [ { - origin: 'JFK', - destination: 'LHR', + origin: 'XXX', + destination: 'YYY', carrier: 'UA', - fare_basis: 'V14NR', - nuc_amount: '450.00', + fare_basis: 'Y', + nuc_amount: '100.00', }, ], - selling_currency: 'USD', - }, + }), }); + expect('status' in result.data && result.data.status).toBe('DOMAIN_INPUT_REQUIRED'); + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + expect(result.data.missing).toContain('tpm:XXX-YYY'); + expect(result.data.description).toContain('haversine'); + expect(result.data.references.some((r) => r.includes('ticketed-point-mileage-tpm'))).toBe( + true, + ); + } + expect(result.confidence).toBe(0); + }); + + it('returns DOMAIN_INPUT_REQUIRED when 024d rounding rule is missing', async () => { + const sources: FareConstructionDataSources = { + ...TEST_SOURCES, + rounding_024d: { ...TEST_SOURCES.rounding_024d }, + }; + delete sources.rounding_024d['USD']; + + const result = await agent.execute({ + data: makeInput({ data_sources: sources }), + }); + + expect('status' in result.data && result.data.status).toBe('DOMAIN_INPUT_REQUIRED'); + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + expect(result.data.missing).toContain('rounding_024d:USD'); + expect(result.data.description).toMatch(/banker|024d/i); + } + }); + }); + + describe('Explicit bans', () => { + it('exports ban constants for haversine, hardcoded IROE, and banker rounding', () => { + expect(HAVERSINE_AS_TPM_BANNED).toMatch(/haversine/i); + expect(HARDCODED_IROE_BANNED).toMatch(/hardcoded IROE/i); + expect(BANKERS_ROUNDING_AS_IATA_BANNED).toMatch(/banker/i); + }); + + it('assertPublishedTpmSource rejects haversine-labelled sources', () => { + expect(() => assertPublishedTpmSource('haversine')).toThrow(/haversine/i); + expect(() => assertPublishedTpmSource('great-circle-approx')).toThrow(/haversine/i); + expect(() => assertPublishedTpmSource('iata_tpm_manual')).not.toThrow(); + }); + + it('engine module does not import greatCircleDistanceKm / haversine helpers', async () => { + const engineSrc = readFileSync(resolve(__dirname, '../fare-engine.ts'), 'utf8'); + expect(engineSrc).not.toMatch(/greatCircleDistanceKm/); + expect(engineSrc).not.toMatch(/haversineDistance/); + expect(engineSrc).not.toMatch(/from '@otaip\/core\/.*regulations/); + // Must not bundle production IROE/TPM JSON + expect(engineSrc).not.toMatch(/roe-rates\.json/); + expect(engineSrc).not.toMatch(/mileage-data\.json/); + expect(engineSrc).not.toMatch(/rounding-rules\.json/); + }); + + it('024d NX is not banker’s half-to-even', () => { + // 1.25 to unit 1: banker → 2 (even) or 1 depending; half-up NX → 1? + // For amount 2.5 with unit 1: half-up → 3; banker half-to-even → 2. + const nx: Rounding024dRule = { unit: '1', method: 'NX' }; + const rounded = apply024dRounding(new Decimal('2.5'), nx); + expect(rounded.toString()).toBe('3'); // half away from zero, NOT banker → 2 + }); + }); + + describe('NUC × IROE construction', () => { + it('constructs JFK-LHR OW fare in USD', async () => { + const result = await agent.execute({ data: makeInput() }); + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + throw new Error('Expected constructed fare'); + } expect(result.data.total_nuc).toBe('450.00'); + expect(result.data.iroe).toBe('1.000000'); expect(result.data.roe).toBe('1.000000'); - // USD ROE=1, so local = NUC = 450.00 expect(result.data.local_amount).toBe('450'); expect(result.data.currency).toBe('USD'); + expect(result.data.rounding_method).toBe('NX'); }); - it('constructs JFK-LHR OW fare in GBP', async () => { + it('constructs JFK-LHR OW fare in GBP with IROE', async () => { const result = await agent.execute({ - data: { - journey_type: 'OW', + data: makeInput({ components: [ { origin: 'JFK', @@ -60,19 +208,19 @@ describe('Fare Construction', () => { }, ], selling_currency: 'GBP', - }, + }), }); - - // NUC 1200 × ROE 0.79365 = GBP 952.38 + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + throw new Error('Expected constructed fare'); + } const expectedRaw = new Decimal('1200.00').mul('0.793650'); - expect(result.data.roe).toBe('0.793650'); + expect(result.data.iroe).toBe('0.793650'); expect(new Decimal(result.data.local_amount_raw).toFixed(2)).toBe(expectedRaw.toFixed(2)); }); - it('constructs fare in JPY with correct rounding (round to 1)', async () => { + it('applies HX rounding for JPY', async () => { const result = await agent.execute({ - data: { - journey_type: 'OW', + data: makeInput({ components: [ { origin: 'SFO', @@ -83,19 +231,19 @@ describe('Fare Construction', () => { }, ], selling_currency: 'JPY', - }, + }), }); - - // NUC 500 × ROE 149.52 = JPY 74760 + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + throw new Error('Expected constructed fare'); + } expect(result.data.rounding_unit).toBe('1'); - // Should be a whole number + expect(result.data.rounding_method).toBe('HX'); expect(result.data.local_amount).toMatch(/^\d+$/); }); - it('constructs fare in CHF with 0.05 rounding', async () => { + it('applies HX 0.05 rounding for CHF', async () => { const result = await agent.execute({ - data: { - journey_type: 'OW', + data: makeInput({ components: [ { origin: 'JFK', @@ -106,42 +254,36 @@ describe('Fare Construction', () => { }, ], selling_currency: 'CHF', - }, + }), }); - + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + throw new Error('Expected constructed fare'); + } expect(result.data.rounding_unit).toBe('0.05'); - // Verify rounding: amount should be divisible by 0.05 + expect(result.data.rounding_method).toBe('HX'); const amount = new Decimal(result.data.local_amount); expect(amount.mod('0.05').eq(0)).toBe(true); }); + }); - it('constructs fare in KRW with 100-unit rounding', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100.00', - }, - ], - selling_currency: 'KRW', - }, - }); + describe('024d apply024dRounding', () => { + it('HX rounds up unless exact', () => { + const hx: Rounding024dRule = { unit: '1', method: 'HX' }; + expect(apply024dRounding(new Decimal('10'), hx).toString()).toBe('10'); + expect(apply024dRounding(new Decimal('10.01'), hx).toString()).toBe('11'); + }); - expect(result.data.rounding_unit).toBe('100'); - const amount = new Decimal(result.data.local_amount); - expect(amount.mod('100').eq(0)).toBe(true); + it('NX rounds to nearest (half up, not banker)', () => { + const nx: Rounding024dRule = { unit: '0.01', method: 'NX' }; + expect(apply024dRounding(new Decimal('10.004'), nx).toFixed(2)).toBe('10.00'); + expect(apply024dRounding(new Decimal('10.005'), nx).toFixed(2)).toBe('10.01'); }); }); - describe('Round-trip (RT) fare construction', () => { - it('constructs JFK-LHR-JFK RT fare', async () => { + describe('Round-trip / CT with published mileage', () => { + it('constructs RT and records TPM from published data', async () => { const result = await agent.execute({ - data: { + data: makeInput({ journey_type: 'RT', components: [ { @@ -159,48 +301,21 @@ describe('Fare Construction', () => { nuc_amount: '450.00', }, ], - selling_currency: 'USD', - }, + }), }); - + if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { + throw new Error('Expected constructed fare'); + } expect(result.data.total_nuc).toBe('900.00'); - expect(result.data.local_amount).toBe('900'); - }); - - it('has mileage checks for each segment', async () => { - const result = await agent.execute({ - data: { - journey_type: 'RT', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '600.00', - }, - { - origin: 'LHR', - destination: 'JFK', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '600.00', - }, - ], - selling_currency: 'USD', - }, - }); - expect(result.data.mileage_checks.length).toBe(2); - expect(result.data.mileage_checks[0]!.data_available).toBe(true); expect(result.data.mileage_checks[0]!.tpm).toBe(3459); + expect(result.data.mileage_checks[0]!.mpm).toBe(3805); + expect(result.data.total_mpm).toBe(result.data.total_mph); }); - }); - describe('Circle trip (CT) fare construction', () => { - it('constructs JFK-LHR-CDG-JFK CT fare', async () => { + it('CT reports CTM missing_inputs instead of inventing CTM=total_nuc', async () => { const result = await agent.execute({ - data: { + data: makeInput({ journey_type: 'CT', components: [ { @@ -225,161 +340,22 @@ describe('Fare Construction', () => { nuc_amount: '480.00', }, ], - selling_currency: 'USD', - }, - }); - - expect(result.data.total_nuc).toBe('1080.00'); - expect(result.data.ctm_check.applies).toBe(true); - }); - - it('CTM not applied for OW journey', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '500.00', - }, - ], - selling_currency: 'USD', - }, - }); - - expect(result.data.ctm_check.applies).toBe(false); - }); - }); - - describe('Mileage exceeded and surcharges', () => { - it('detects mileage not exceeded for direct JFK-LHR', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '500.00', - }, - ], - selling_currency: 'USD', - }, - }); - - expect(result.data.mileage_exceeded).toBe(false); - expect(result.data.mileage_surcharge.applies).toBe(false); - }); - - it('warns when mileage data is unavailable', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'XXX', - destination: 'YYY', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100.00', - }, - ], - selling_currency: 'USD', - }, - }); - - expect(result.warnings).toBeDefined(); - expect(result.warnings!.some((w) => w.includes('No mileage data'))).toBe(true); - }); - }); - - describe('HIP single-component', () => { - it('detects no HIP for simple direct fare', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '500.00', - }, - ], - selling_currency: 'USD', - }, - }); - - if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { - throw new Error('Expected normal output'); - } - expect(result.data.hip_check.detected).toBe(false); - expect(result.data.hip_check.missing_inputs).toBeUndefined(); - }); - }); - - describe('BHC detection', () => { - it('reports BHC undetected with DOMAIN_INPUT_REQUIRED for multi-component fares', async () => { - // Real BHC requires geographic-direction analysis. Simple - // city-revisited heuristics were a CLAUDE.md violation and removed. - const result = await agent.execute({ - data: { - journey_type: 'CT', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '400.00', - }, - { - origin: 'LHR', - destination: 'CDG', - carrier: 'AF', - fare_basis: 'Y', - nuc_amount: '100.00', - }, - { - origin: 'CDG', - destination: 'LHR', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '100.00', - }, - { - origin: 'LHR', - destination: 'JFK', - carrier: 'BA', - fare_basis: 'Y', - nuc_amount: '400.00', - }, - ], - selling_currency: 'USD', - }, + }), }); - if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { - throw new Error('Expected normal output, got DOMAIN_INPUT_REQUIRED'); + throw new Error('Expected constructed fare'); } - expect(result.data.bhc_check.detected).toBe(false); - expect(result.data.bhc_check.missing_inputs).toBeDefined(); - expect(result.data.bhc_check.missing_inputs!.length).toBeGreaterThan(0); - expect(result.warnings!.some((w) => w.includes('DOMAIN_INPUT_REQUIRED (BHC)'))).toBe(true); + expect(result.data.ctm_check.applies).toBe(false); + expect(result.data.ctm_check.ctm_nuc).toBeNull(); + expect(result.data.ctm_check.missing_inputs).toBeDefined(); + expect(result.warnings!.some((w) => w.includes('DOMAIN_INPUT_REQUIRED (CTM)'))).toBe(true); }); }); - describe('HIP detection', () => { - it('reports HIP undetected with DOMAIN_INPUT_REQUIRED for multi-component fares', async () => { + describe('HIP / BHC sketches', () => { + it('HIP undetected with missing_inputs for multi-component', async () => { const result = await agent.execute({ - data: { - journey_type: 'OW', + data: makeInput({ components: [ { origin: 'JFK', @@ -396,235 +372,67 @@ describe('Fare Construction', () => { nuc_amount: '200.00', }, ], - selling_currency: 'USD', - }, + }), }); if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { - throw new Error('Expected normal output, got DOMAIN_INPUT_REQUIRED'); + throw new Error('Expected constructed fare'); } expect(result.data.hip_check.detected).toBe(false); - expect(result.data.hip_check.missing_inputs).toBeDefined(); - expect(result.warnings!.some((w) => w.includes('DOMAIN_INPUT_REQUIRED (HIP)'))).toBe(true); + expect(result.data.hip_check.missing_inputs!.length).toBeGreaterThan(0); + expect(result.data.bhc_check.detected).toBe(false); + expect(result.data.bhc_check.missing_inputs!.length).toBeGreaterThan(0); }); }); describe('Audit trail', () => { - it('produces a 12-step audit trail', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '500.00', - }, - ], - selling_currency: 'USD', - }, - }); - - expect(result.data.audit_trail.length).toBe(12); - expect(result.data.audit_trail[0]!.step).toBe(1); - expect(result.data.audit_trail[11]!.step).toBe(12); - expect(result.data.audit_trail[11]!.name).toBe('Final Fare'); - }); - }); - - describe('ROE conversion', () => { - it('uses correct ROE for EUR', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'CDG', - carrier: 'AF', - fare_basis: 'Y', - nuc_amount: '1000.00', - }, - ], - selling_currency: 'EUR', - }, - }); - - expect(result.data.roe).toBe('0.920830'); - const expected = new Decimal('1000.00').mul('0.920830'); - expect(new Decimal(result.data.local_amount_raw).toFixed(2)).toBe(expected.toFixed(2)); - }); - - it('returns DOMAIN_INPUT_REQUIRED for unknown currency (no ROE fallback)', async () => { - // Previous behaviour silently fell back to 1.0, producing wrong fares. - // Now refuses to construct without an authoritative ROE. - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100.00', - }, - ], - selling_currency: 'XYZ', - }, - }); - - expect('status' in result.data && result.data.status).toBe('DOMAIN_INPUT_REQUIRED'); + it('produces a full audit trail ending in Final Fare', async () => { + const result = await agent.execute({ data: makeInput() }); if ('status' in result.data && result.data.status === 'DOMAIN_INPUT_REQUIRED') { - expect(result.data.missing).toContain('roe_table_entry:XYZ'); - expect(result.data.references).toContain('IATA monthly ROE publication'); + throw new Error('Expected constructed fare'); } - expect(result.confidence).toBe(0); - expect(result.warnings!.some((w) => w.includes('DOMAIN_INPUT_REQUIRED'))).toBe(true); + expect(result.data.audit_trail.length).toBeGreaterThanOrEqual(12); + expect(result.data.audit_trail[0]!.step).toBe(1); + expect(result.data.audit_trail.at(-1)!.name).toBe('Final Fare'); }); }); describe('Input validation', () => { it('rejects invalid journey_type', async () => { await expect( - agent.execute({ - data: { - journey_type: 'XX' as 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100', - }, - ], - selling_currency: 'USD', - }, - }), + agent.execute({ data: makeInput({ journey_type: 'XX' as 'OW' }) }), ).rejects.toThrow('Invalid input'); }); it('rejects empty components', async () => { await expect( - agent.execute({ - data: { journey_type: 'OW', components: [], selling_currency: 'USD' }, - }), - ).rejects.toThrow('Invalid input'); - }); - - it('rejects invalid origin in component', async () => { - await expect( - agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: '1', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100', - }, - ], - selling_currency: 'USD', - }, - }), - ).rejects.toThrow('Invalid input'); - }); - - it('rejects invalid NUC amount', async () => { - await expect( - agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: 'abc', - }, - ], - selling_currency: 'USD', - }, - }), + agent.execute({ data: makeInput({ components: [] }) }), ).rejects.toThrow('Invalid input'); }); it('rejects invalid currency', async () => { await expect( - agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100', - }, - ], - selling_currency: 'us', - }, - }), + agent.execute({ data: makeInput({ selling_currency: 'us' }) }), ).rejects.toThrow('Invalid input'); }); }); - describe('Agent interface compliance', () => { + describe('Agent interface', () => { it('has correct metadata', () => { expect(agent.id).toBe('2.2'); expect(agent.name).toBe('Fare Construction'); - expect(agent.version).toBe('0.1.0'); + expect(agent.version).toBe('0.2.0'); }); - it('reports healthy', async () => { - const health = await agent.health(); - expect(health.status).toBe('healthy'); - }); - - it('returns metadata in output', async () => { - const result = await agent.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100', - }, - ], - selling_currency: 'USD', - }, - }); - expect(result.metadata!['agent_id']).toBe('2.2'); + it('constructFare is exported for direct unit use', () => { + const result = constructFare(makeInput()); + expect('total_nuc' in result || ('status' in result && result.status === 'DOMAIN_INPUT_REQUIRED')).toBe( + true, + ); }); it('throws when not initialized', async () => { const uninit = new FareConstruction(); - await expect( - uninit.execute({ - data: { - journey_type: 'OW', - components: [ - { - origin: 'JFK', - destination: 'LHR', - carrier: 'UA', - fare_basis: 'Y', - nuc_amount: '100', - }, - ], - selling_currency: 'USD', - }, - }), - ).rejects.toThrow('not been initialized'); + await expect(uninit.execute({ data: makeInput() })).rejects.toThrow('not been initialized'); }); }); }); diff --git a/packages/agents/pricing/src/fare-construction/__tests__/fixtures/test-fare-construction-data.json b/packages/agents/pricing/src/fare-construction/__tests__/fixtures/test-fare-construction-data.json new file mode 100644 index 0000000..993f34f --- /dev/null +++ b/packages/agents/pricing/src/fare-construction/__tests__/fixtures/test-fare-construction-data.json @@ -0,0 +1,27 @@ +{ + "description": "TEST FIXTURE — invented numbers for unit tests only. Do NOT use in production. Real IROE must come from the licensed IATA Rates of Exchange feed. Do not commit proprietary IROE files to the repo.", + "iroe": { + "USD": "1.000000", + "EUR": "0.920830", + "GBP": "0.793650", + "JPY": "149.520000", + "CHF": "0.882460", + "KRW": "1312.500000" + }, + "rounding_024d": { + "USD": { "unit": "0.01", "method": "NX" }, + "EUR": { "unit": "0.01", "method": "HX" }, + "GBP": { "unit": "0.01", "method": "HX" }, + "JPY": { "unit": "1", "method": "HX" }, + "CHF": { "unit": "0.05", "method": "HX" }, + "KRW": { "unit": "100", "method": "HX" } + }, + "mileage": [ + { "origin": "JFK", "destination": "LHR", "tpm": 3459, "mpm": 3805 }, + { "origin": "JFK", "destination": "CDG", "tpm": 3635, "mpm": 3999 }, + { "origin": "LHR", "destination": "CDG", "tpm": 215, "mpm": 237 }, + { "origin": "CDG", "destination": "JFK", "tpm": 3635, "mpm": 3999 }, + { "origin": "SFO", "destination": "NRT", "tpm": 5130, "mpm": 5643 }, + { "origin": "LHR", "destination": "JFK", "tpm": 3459, "mpm": 3805 } + ] +} diff --git a/packages/agents/pricing/src/fare-construction/data/mileage-data.json b/packages/agents/pricing/src/fare-construction/data/mileage-data.json deleted file mode 100644 index c265cf6..0000000 --- a/packages/agents/pricing/src/fare-construction/data/mileage-data.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "_comment": "Curated TPM/MPM data for ~50 major city pairs. NOT official IATA data.", - "_version": "0.1.0", - "city_pairs": [ - { "origin": "JFK", "destination": "LHR", "tpm": 3459, "mph": 3805 }, - { "origin": "JFK", "destination": "CDG", "tpm": 3635, "mph": 3999 }, - { "origin": "JFK", "destination": "FRA", "tpm": 3857, "mph": 4243 }, - { "origin": "JFK", "destination": "LAX", "tpm": 2475, "mph": 2723 }, - { "origin": "JFK", "destination": "ORD", "tpm": 740, "mph": 814 }, - { "origin": "JFK", "destination": "MIA", "tpm": 1089, "mph": 1198 }, - { "origin": "JFK", "destination": "NRT", "tpm": 6757, "mph": 7433 }, - { "origin": "JFK", "destination": "SIN", "tpm": 9523, "mph": 10475 }, - { "origin": "JFK", "destination": "DXB", "tpm": 6842, "mph": 7526 }, - { "origin": "SFO", "destination": "NRT", "tpm": 5130, "mph": 5643 }, - { "origin": "SFO", "destination": "LAX", "tpm": 337, "mph": 371 }, - { "origin": "SFO", "destination": "LHR", "tpm": 5367, "mph": 5904 }, - { "origin": "SFO", "destination": "HKG", "tpm": 6927, "mph": 7620 }, - { "origin": "LAX", "destination": "NRT", "tpm": 5451, "mph": 5996 }, - { "origin": "LAX", "destination": "LHR", "tpm": 5456, "mph": 6002 }, - { "origin": "LAX", "destination": "SYD", "tpm": 7488, "mph": 8237 }, - { "origin": "ORD", "destination": "LHR", "tpm": 3952, "mph": 4347 }, - { "origin": "ORD", "destination": "NRT", "tpm": 6292, "mph": 6921 }, - { "origin": "ORD", "destination": "MIA", "tpm": 1197, "mph": 1317 }, - { "origin": "ORD", "destination": "LAX", "tpm": 1745, "mph": 1920 }, - { "origin": "LHR", "destination": "CDG", "tpm": 215, "mph": 237 }, - { "origin": "LHR", "destination": "FRA", "tpm": 394, "mph": 433 }, - { "origin": "LHR", "destination": "DXB", "tpm": 3414, "mph": 3755 }, - { "origin": "LHR", "destination": "SIN", "tpm": 6756, "mph": 7432 }, - { "origin": "LHR", "destination": "HKG", "tpm": 5994, "mph": 6593 }, - { "origin": "LHR", "destination": "NRT", "tpm": 5974, "mph": 6571 }, - { "origin": "LHR", "destination": "SYD", "tpm": 10553, "mph": 11608 }, - { "origin": "CDG", "destination": "NRT", "tpm": 6194, "mph": 6813 }, - { "origin": "CDG", "destination": "SIN", "tpm": 6664, "mph": 7330 }, - { "origin": "FRA", "destination": "NRT", "tpm": 5928, "mph": 6521 }, - { "origin": "FRA", "destination": "SIN", "tpm": 6368, "mph": 7005 }, - { "origin": "FRA", "destination": "DXB", "tpm": 3003, "mph": 3303 }, - { "origin": "SIN", "destination": "SYD", "tpm": 3907, "mph": 4298 }, - { "origin": "SIN", "destination": "NRT", "tpm": 3312, "mph": 3643 }, - { "origin": "SIN", "destination": "HKG", "tpm": 1605, "mph": 1766 }, - { "origin": "HKG", "destination": "NRT", "tpm": 1791, "mph": 1970 }, - { "origin": "HKG", "destination": "SYD", "tpm": 4580, "mph": 5038 }, - { "origin": "NRT", "destination": "SYD", "tpm": 4863, "mph": 5349 }, - { "origin": "DXB", "destination": "SIN", "tpm": 3659, "mph": 4025 }, - { "origin": "DXB", "destination": "LHR", "tpm": 3414, "mph": 3755 }, - { "origin": "DXB", "destination": "NRT", "tpm": 4953, "mph": 5448 }, - { "origin": "MIA", "destination": "LHR", "tpm": 4429, "mph": 4872 }, - { "origin": "MIA", "destination": "CDG", "tpm": 4587, "mph": 5046 }, - { "origin": "ATL", "destination": "LHR", "tpm": 4199, "mph": 4619 }, - { "origin": "ATL", "destination": "CDG", "tpm": 4383, "mph": 4821 }, - { "origin": "DFW", "destination": "LHR", "tpm": 4751, "mph": 5226 }, - { "origin": "DEN", "destination": "LHR", "tpm": 4673, "mph": 5140 }, - { "origin": "SEA", "destination": "NRT", "tpm": 4783, "mph": 5261 }, - { "origin": "ICN", "destination": "NRT", "tpm": 757, "mph": 833 }, - { "origin": "ICN", "destination": "SIN", "tpm": 2877, "mph": 3165 } - ] -} diff --git a/packages/agents/pricing/src/fare-construction/data/roe-rates.json b/packages/agents/pricing/src/fare-construction/data/roe-rates.json deleted file mode 100644 index ceadd33..0000000 --- a/packages/agents/pricing/src/fare-construction/data/roe-rates.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_comment": "IATA Rate of Exchange (ROE) rates for NUC→local currency conversion. NOT official IATA data — representative values.", - "_version": "0.1.0", - "_effective_date": "2025-01-01", - "rates": { - "USD": "1.000000", - "EUR": "0.920830", - "GBP": "0.793650", - "JPY": "149.520000", - "CHF": "0.882460", - "CAD": "1.356780", - "AUD": "1.532400", - "SGD": "1.342560", - "HKD": "7.812000", - "KRW": "1312.500000", - "THB": "35.240000", - "INR": "83.120000", - "MYR": "4.725000", - "NZD": "1.637500", - "ZAR": "18.925000", - "AED": "3.672800", - "SAR": "3.750000", - "BRL": "4.987000", - "MXN": "17.125000", - "CNY": "7.245000" - } -} diff --git a/packages/agents/pricing/src/fare-construction/data/rounding-rules.json b/packages/agents/pricing/src/fare-construction/data/rounding-rules.json deleted file mode 100644 index e9f761c..0000000 --- a/packages/agents/pricing/src/fare-construction/data/rounding-rules.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_comment": "IATA rounding rules per currency. unit = smallest rounding increment, direction = up/nearest/down.", - "_version": "0.1.0", - "rules": { - "USD": { "unit": "0.01", "direction": "up" }, - "EUR": { "unit": "0.01", "direction": "up" }, - "GBP": { "unit": "0.01", "direction": "up" }, - "JPY": { "unit": "1", "direction": "up" }, - "CHF": { "unit": "0.05", "direction": "up" }, - "CAD": { "unit": "0.01", "direction": "up" }, - "AUD": { "unit": "0.01", "direction": "up" }, - "SGD": { "unit": "0.01", "direction": "up" }, - "HKD": { "unit": "1", "direction": "up" }, - "KRW": { "unit": "100", "direction": "up" }, - "THB": { "unit": "1", "direction": "up" }, - "INR": { "unit": "1", "direction": "up" }, - "MYR": { "unit": "0.05", "direction": "up" }, - "NZD": { "unit": "0.01", "direction": "up" }, - "ZAR": { "unit": "0.01", "direction": "up" }, - "AED": { "unit": "0.01", "direction": "up" }, - "SAR": { "unit": "0.01", "direction": "up" }, - "BRL": { "unit": "0.01", "direction": "up" }, - "MXN": { "unit": "0.01", "direction": "up" }, - "CNY": { "unit": "0.01", "direction": "up" } - }, - "default": { "unit": "0.01", "direction": "up" } -} diff --git a/packages/agents/pricing/src/fare-construction/fare-engine.ts b/packages/agents/pricing/src/fare-construction/fare-engine.ts index add2b55..7954c19 100644 --- a/packages/agents/pricing/src/fare-construction/fare-engine.ts +++ b/packages/agents/pricing/src/fare-construction/fare-engine.ts @@ -1,21 +1,16 @@ /** - * Fare Construction Engine — 12-step pipeline. + * Fare Construction Engine — NUC × IROE pipeline with fail-closed data deps. * - * All financial math uses decimal.js. + * Authoritative KB: + * docs/knowledge-base/fare-construction-data-dependencies.md * - * // DOMAIN_QUESTION: ROE source-of-truth - * // ROE values are published by IATA monthly. Hardcoded ROE values produce - * // wrong fares immediately for any currency that drifts. The previous - * // 1.0 fallback was a CLAUDE.md violation. We now refuse to construct - * // fares for currencies whose ROE is not in the input data and instead - * // return DOMAIN_INPUT_REQUIRED listing the missing ROE. + * BANS (CLAUDE.md Agent 2.2 / issue #151): + * - No hardcoded IROE in this module (caller supplies licensed feed). + * - No haversine / great-circle as TPM. + * - No IEEE banker's / half-to-even rounding as IATA 024d. + * - No inventing HIP/BHC/CTM comparison rules. * - * // DOMAIN_QUESTION: HIP/BHC fare lookup - * // Real HIP/BHC detection requires per-carrier filed fares between every - * // intermediate point in the routing. The simplified heuristics that - * // previously lived here (per-mile rate comparison and string-matching - * // city revisits) were CLAUDE.md violations. We now report these checks - * // as undetected with `missing_inputs` listing the lookup data needed. + * Missing IROE, published TPM/MPM, or 024d rounding → DomainInputRequired. */ import { Decimal } from 'decimal.js'; @@ -23,84 +18,147 @@ import { domainInputRequired, isDomainInputRequired } from '@otaip/core'; import type { FareConstructionInput, FareConstructionResult, + FareConstructionDataSources, MileageCheck, MileageSurcharge, HipCheck, BhcCheck, CtmCheck, AuditStep, + Rounding024dRule, + Rounding024dMethod, + PublishedCityPairMileage, } from './types.js'; -// JSON imported directly so esbuild inlines it into dist/index.js — using -// createRequire on the bundled output would fail with MODULE_NOT_FOUND when -// this package is consumed as a built dep. -import mileageJson from './data/mileage-data.json'; -import roeJson from './data/roe-rates.json'; -import roundingJson from './data/rounding-rules.json'; export { isDomainInputRequired }; // --------------------------------------------------------------------------- -// Data loading +// Explicit bans (documented + testable) // --------------------------------------------------------------------------- -interface CityPair { - origin: string; - destination: string; - tpm: number; - mph: number; -} -interface MileageData { - city_pairs: CityPair[]; -} -interface RoeData { - rates: Record; -} -interface RoundingRule { - unit: string; - direction: string; -} -interface RoundingData { - rules: Record; - default: RoundingRule; -} +/** + * TPM must come from the IATA TPM Manual (or equivalent licensed feed). + * Haversine / great-circle distance is NOT TPM. + * + * @see https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/ + */ +export const HAVERSINE_AS_TPM_BANNED = + 'BAN: haversine/great-circle distance must never be used as TPM. Use published IATA TPM.'; -const mileageData = mileageJson as unknown as MileageData; -const roeData = roeJson as unknown as RoeData; -const roundingData = roundingJson as unknown as RoundingData; +/** + * IROE must be ingested from the licensed monthly (periodic) IATA feed. + * Hardcoded rates in the engine module graph are forbidden. + */ +export const HARDCODED_IROE_BANNED = + 'BAN: no hardcoded IROE in production. Pass licensed rates via data_sources.iroe.'; + +/** + * Resolution 024d uses HX/NX — not banker's (half-to-even) rounding. + */ +export const BANKERS_ROUNDING_AS_IATA_BANNED = + "BAN: IEEE banker's / half-to-even rounding is not Resolution 024d. Use HX or NX from the licensed table."; + +/** Reject any attempt to mark mileage as haversine-sourced. */ +export function assertPublishedTpmSource(source: string): void { + const normalized = source.trim().toLowerCase(); + if ( + normalized.includes('haversine') || + normalized.includes('great-circle') || + normalized.includes('great_circle') || + normalized.includes('geodesic') + ) { + throw new Error(HAVERSINE_AS_TPM_BANNED); + } +} // --------------------------------------------------------------------------- -// Helpers +// Lookups (from caller-supplied licensed data only) // --------------------------------------------------------------------------- -function findMileage(origin: string, destination: string): CityPair | undefined { - return mileageData.city_pairs.find( +function findMileage( + mileage: PublishedCityPairMileage[], + origin: string, + destination: string, +): PublishedCityPairMileage | undefined { + return mileage.find( (cp) => (cp.origin === origin && cp.destination === destination) || (cp.origin === destination && cp.destination === origin), ); } -function getRoe(currency: string): Decimal | null { - const rate = roeData.rates[currency]; - if (!rate) return null; +function getIroe(sources: FareConstructionDataSources, currency: string): Decimal | null { + const rate = sources.iroe[currency]; + if (rate === undefined || rate === '') return null; return new Decimal(rate); } -function getRoundingRule(currency: string): RoundingRule { - return roundingData.rules[currency] ?? roundingData.default; +function getRounding024d( + sources: FareConstructionDataSources, + currency: string, +): Rounding024dRule | null { + const rule = sources.rounding_024d[currency]; + if (!rule) return null; + if (rule.method !== 'HX' && rule.method !== 'NX') return null; + if (!rule.unit) return null; + return rule; } /** - * IATA rounding: round UP to the nearest unit. + * Apply Resolution 024d rounding for a supplied unit + method. + * Mechanical HX/NX only — does not invent which currency uses which rule. + * + * HX: round up to next higher unit unless already exact. + * NX: round to nearest unit (half away from zero / commercial half-up). + * Banker's (half-to-even) is explicitly not used. */ -function iataRound(amount: Decimal, unit: string): Decimal { - const u = new Decimal(unit); - // Divide by unit, round up (ceiling), multiply back - return amount.div(u).ceil().mul(u); +export function apply024dRounding( + amount: Decimal, + rule: Rounding024dRule, +): Decimal { + const unit = new Decimal(rule.unit); + if (unit.lte(0)) { + throw new Error('024d rounding unit must be positive'); + } + + const divided = amount.div(unit); + + if (rule.method === 'HX') { + // Round up to next higher unit unless already exact. + if (divided.isInteger()) return amount; + return divided.ceil().mul(unit); + } + + // NX — nearest unit. Half rounds away from zero (not banker's half-to-even). + const sign = divided.isNegative() ? -1 : 1; + const abs = divided.abs(); + const floor = abs.floor(); + const frac = abs.minus(floor); + const half = new Decimal('0.5'); + const roundedAbs = frac.gte(half) ? floor.plus(1) : floor; + return roundedAbs.mul(sign).mul(unit); +} + +function missingDataSourcesResult() { + return domainInputRequired({ + missing: [ + 'data_sources.iroe', + 'data_sources.rounding_024d', + 'data_sources.mileage', + ], + description: + 'Fare construction requires licensed IROE, Resolution 024d rounding, and published TPM/MPM via data_sources. No hardcoded rates or haversine substitutes.', + references: [ + 'IATA Rates of Exchange (IROE)', + 'IATA Resolution 024d', + 'https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/', + 'docs/knowledge-base/fare-construction-data-dependencies.md', + ], + }); } // --------------------------------------------------------------------------- -// Pipeline steps +// Pipeline // --------------------------------------------------------------------------- export function constructFare(input: FareConstructionInput): FareConstructionResult { @@ -112,7 +170,7 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes audit.push({ step: stepNum, name, description, input: inputVal, output: outputVal }); } - // Step 1: Validate components + // Step 1: Validate components present (schema validated by agent) addStep( 'Validate Components', `${input.components.length} fare component(s), journey type ${input.journey_type}`, @@ -120,7 +178,31 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes 'valid', ); - // Step 2: Sum NUC amounts + // Step 2: Require licensed data_sources (fail closed — no bundled IROE/TPM) + const sources = input.data_sources; + if ( + !sources || + !sources.iroe || + !sources.rounding_024d || + !Array.isArray(sources.mileage) + ) { + addStep( + 'Data Sources', + 'Licensed IROE / 024d / TPM-MPM not provided', + 'data_sources', + 'DOMAIN_INPUT_REQUIRED', + ); + return missingDataSourcesResult(); + } + + addStep( + 'Data Sources', + 'Caller-supplied licensed IROE, 024d, and TPM/MPM present', + `iroe_keys=${Object.keys(sources.iroe).length}; mileage_rows=${sources.mileage.length}`, + 'ok', + ); + + // Step 3: Sum NUC amounts let totalNuc = new Decimal(0); for (const comp of input.components) { totalNuc = totalNuc.plus(new Decimal(comp.nuc_amount)); @@ -132,54 +214,76 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes totalNuc.toFixed(2), ); - // Step 3: Mileage validation + // Step 4: Published TPM/MPM lookup — fail closed if any pair missing const mileageChecks: MileageCheck[] = []; + const missingTpm: string[] = []; let totalTpm = 0; - let totalMph = 0; + let totalMpm = 0; for (const comp of input.components) { - const cp = findMileage(comp.origin, comp.destination); + const cp = findMileage(sources.mileage, comp.origin, comp.destination); if (cp) { mileageChecks.push({ origin: comp.origin, destination: comp.destination, tpm: cp.tpm, - mph: cp.mph, + mpm: cp.mpm, data_available: true, }); totalTpm += cp.tpm; - totalMph += cp.mph; + totalMpm += cp.mpm; } else { + missingTpm.push(`tpm:${comp.origin}-${comp.destination}`); mileageChecks.push({ origin: comp.origin, destination: comp.destination, tpm: null, - mph: null, + mpm: null, data_available: false, }); } } + if (missingTpm.length > 0) { + addStep( + 'Mileage Validation', + 'Published TPM missing — refusing haversine substitute', + missingTpm.join(', '), + 'DOMAIN_INPUT_REQUIRED', + ); + return domainInputRequired({ + missing: missingTpm, + description: `${HAVERSINE_AS_TPM_BANNED} Missing published TPM for: ${missingTpm.join(', ')}.`, + references: [ + 'https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/', + 'IATA Maximum Permitted Mileage Manual', + 'docs/knowledge-base/fare-construction-data-dependencies.md', + ], + }); + } + addStep( 'Mileage Validation', - `TPM total: ${totalTpm}, MPM total: ${totalMph}`, + `TPM total: ${totalTpm}, MPM total: ${totalMpm}`, `${mileageChecks.length} segments`, - `TPM=${totalTpm} MPM=${totalMph}`, + `TPM=${totalTpm} MPM=${totalMpm}`, ); - // Step 4: Check mileage exceeded - const mileageExceeded = totalMph > 0 && totalTpm > totalMph; + // Step 5: MPM excess check (published mileages only) + const mileageExceeded = totalMpm > 0 && totalTpm > totalMpm; const excessPct = - totalMph > 0 ? new Decimal(totalTpm).minus(totalMph).div(totalMph).mul(100).toNumber() : 0; + totalMpm > 0 ? new Decimal(totalTpm).minus(totalMpm).div(totalMpm).mul(100).toNumber() : 0; addStep( 'Mileage Excess Check', `Excess: ${excessPct.toFixed(1)}%`, - `TPM=${totalTpm} vs MPM=${totalMph}`, + `TPM=${totalTpm} vs MPM=${totalMpm}`, mileageExceeded ? `exceeded by ${excessPct.toFixed(1)}%` : 'within MPM', ); - // Step 5: Mileage surcharge + // Step 6: Mileage surcharge (EMS bands — requires published TPM/MPM above) + // TODO: DOMAIN_QUESTION: confirm EMS percentage bands against current IATA + // mileage system documentation for each market; do not extend beyond 5% steps. let surchargePercentage = 0; if (mileageExceeded) { if (excessPct <= 5) surchargePercentage = 5; @@ -211,12 +315,7 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes `total NUC=${totalNuc.toFixed(2)}`, ); - // Step 6: HIP check (Higher Intermediate Point) - // Real HIP detection requires per-airline filed fares between every - // intermediate point in the routing. Without that lookup data, we - // report `detected: false` and surface the missing inputs. We do NOT - // apply per-mile-rate heuristics — those are not the published ATPCO - // HIP comparison rule. + // Step 7: HIP sketch — no invented comparison rules const hipMissing: string[] = []; if (input.components.length > 1) { for (let i = 0; i < input.components.length - 1; i++) { @@ -242,15 +341,9 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes hipMissing.length > 0 ? 'skipped — domain input required' : 'no HIP', ); - // Step 7: BHC check (Backhaul Check) - // Real BHC requires geographic direction analysis (great-circle bearing - // of each fare component vs. intended journey direction). Simple "city - // revisited" string matching is not the published BHC rule. We report - // `detected: false` and list the missing inputs. + // Step 8: BHC sketch const bhcMissing = - input.components.length > 1 - ? ['geographic_direction_analysis:fare_components'] - : []; + input.components.length > 1 ? ['geographic_direction_analysis:fare_components'] : []; const bhcCheck: BhcCheck = { detected: false, description: @@ -267,93 +360,114 @@ export function constructFare(input: FareConstructionInput): FareConstructionRes bhcMissing.length > 0 ? 'skipped — domain input required' : 'no BHC', ); - // Step 8: CTM check (Circle Trip Minimum) + // Step 9: CTM sketch — do NOT invent CTM = total_nuc + const ctmMissing = + input.journey_type === 'CT' && input.components.length >= 2 + ? ['circle_trip_minima_nuc:filed_half_rt_fares'] + : []; const ctmCheck: CtmCheck = { applies: false, ctm_nuc: null, - description: 'CTM not applicable (not a circle trip)', + description: + ctmMissing.length > 0 + ? 'CTM check skipped — filed circle-trip minima not provided.' + : 'CTM not applicable (not a multi-component circle trip)', + ...(ctmMissing.length > 0 ? { missing_inputs: ctmMissing } : {}), }; - if (input.journey_type === 'CT' && input.components.length >= 2) { - // CTM = sum of half round-trip fares for each component - // Simplified: CTM = total_nuc (already the minimum) - ctmCheck.applies = true; - ctmCheck.ctm_nuc = totalNuc.toFixed(2); - ctmCheck.description = 'Circle Trip Minimum applies'; - } - addStep( 'CTM Check', ctmCheck.description, input.journey_type, - ctmCheck.applies ? `CTM NUC=${ctmCheck.ctm_nuc}` : 'N/A', + ctmMissing.length > 0 ? 'skipped — domain input required' : 'N/A', ); - // Step 9: Get ROE - // No fallback. ROE values are published by IATA monthly. Returning - // anything else (especially 1.0) silently produces wrong fares for - // every non-USD currency. If ROE is missing → DomainInputRequired. - const roe = getRoe(input.selling_currency); - if (!roe) { + // Step 10: IROE lookup — no hardcoded fallback + const iroe = getIroe(sources, input.selling_currency); + if (!iroe) { addStep( - 'ROE Lookup', - `No ROE for ${input.selling_currency} — refusing to construct fare.`, + 'IROE Lookup', + `No IROE for ${input.selling_currency} — refusing to construct fare.`, input.selling_currency, 'DOMAIN_INPUT_REQUIRED', ); return domainInputRequired({ - missing: [`roe_table_entry:${input.selling_currency}`], - description: `No ROE entry for ${input.selling_currency}. Construction halted to avoid producing an incorrect local-currency fare.`, - references: ['IATA monthly ROE publication', 'ATPCO Fare Construction guide'], + missing: [`iroe_table_entry:${input.selling_currency}`], + description: `${HARDCODED_IROE_BANNED} No IROE entry for ${input.selling_currency}.`, + references: [ + 'IATA Rates of Exchange (IROE)', + 'docs/knowledge-base/fare-construction-data-dependencies.md', + ], }); } addStep( - 'ROE Lookup', - `ROE for ${input.selling_currency}`, + 'IROE Lookup', + `IROE for ${input.selling_currency}`, input.selling_currency, - roe.toFixed(6), + iroe.toFixed(6), ); - const effectiveRoe = roe; - - // Step 10: NUC × ROE = local currency - const localRaw = totalNuc.mul(effectiveRoe); + // Step 11: NUC × IROE = local currency + const localRaw = totalNuc.mul(iroe); addStep( - 'NUC × ROE', - `${totalNuc.toFixed(2)} × ${effectiveRoe.toFixed(6)}`, + 'NUC × IROE', + `${totalNuc.toFixed(2)} × ${iroe.toFixed(6)}`, `NUC ${totalNuc.toFixed(2)}`, `${input.selling_currency} ${localRaw.toFixed(6)}`, ); - // Step 11: IATA rounding - const roundingRule = getRoundingRule(input.selling_currency); - const localRounded = iataRound(localRaw, roundingRule.unit); + // Step 12: Resolution 024d rounding — no default unit / no banker's + const roundingRule = getRounding024d(sources, input.selling_currency); + if (!roundingRule) { + addStep( + '024d Rounding', + `No 024d rule for ${input.selling_currency}`, + input.selling_currency, + 'DOMAIN_INPUT_REQUIRED', + ); + return domainInputRequired({ + missing: [`rounding_024d:${input.selling_currency}`], + description: `${BANKERS_ROUNDING_AS_IATA_BANNED} No Resolution 024d entry for ${input.selling_currency}.`, + references: [ + 'IATA Resolution 024d', + 'docs/knowledge-base/fare-construction-data-dependencies.md', + ], + }); + } + + const localRounded = apply024dRounding(localRaw, roundingRule); + const method: Rounding024dMethod = roundingRule.method; addStep( - 'IATA Rounding', - `Round UP to nearest ${roundingRule.unit}`, + '024d Rounding', + `${method} to nearest unit ${roundingRule.unit}`, localRaw.toFixed(6), localRounded.toString(), ); - // Step 12: Final result + // Step 13: Final result addStep( 'Final Fare', `Constructed fare in ${input.selling_currency}`, - `NUC ${totalNuc.toFixed(2)} × ROE ${effectiveRoe.toFixed(6)}`, + `NUC ${totalNuc.toFixed(2)} × IROE ${iroe.toFixed(6)}`, `${input.selling_currency} ${localRounded.toString()}`, ); + const iroeStr = iroe.toFixed(6); + return { total_nuc: totalNuc.toFixed(2), - roe: effectiveRoe.toFixed(6), + iroe: iroeStr, + roe: iroeStr, local_amount_raw: localRaw.toFixed(6), local_amount: localRounded.toString(), currency: input.selling_currency, rounding_unit: roundingRule.unit, + rounding_method: method, mileage_checks: mileageChecks, total_tpm: totalTpm, - total_mph: totalMph, + total_mpm: totalMpm, + total_mph: totalMpm, mileage_exceeded: mileageExceeded, mileage_surcharge: mileageSurcharge, hip_check: hipCheck, diff --git a/packages/agents/pricing/src/fare-construction/index.ts b/packages/agents/pricing/src/fare-construction/index.ts index 0aba705..d9ce111 100644 --- a/packages/agents/pricing/src/fare-construction/index.ts +++ b/packages/agents/pricing/src/fare-construction/index.ts @@ -1,12 +1,13 @@ /** * Fare Construction — Agent 2.2 * - * NUC × ROE fare construction with mileage validation, - * HIP/BHC/CTM checks, surcharges, and IATA rounding. + * NUC × IROE with published TPM/MPM, Resolution 024d (HX/NX) rounding, + * and fail-closed HIP/BHC/CTM hooks. * - * ALL financial math uses decimal.js — no floating point for currency. + * Licensed data is supplied via input.data_sources — this package does + * not ship IROE or TPM files. * - * Implements the base Agent interface from @otaip/core. + * KB: docs/knowledge-base/fare-construction-data-dependencies.md */ import type { Agent, AgentInput, AgentOutput, AgentHealthStatus } from '@otaip/core'; @@ -29,7 +30,7 @@ const CURRENCY_RE = /^[A-Z]{3}$/; export class FareConstruction implements Agent { readonly id = '2.2'; readonly name = 'Fare Construction'; - readonly version = '0.1.0'; + readonly version = '0.2.0'; private initialized = false; @@ -70,7 +71,7 @@ export class FareConstruction implements Agent MPM ${result.total_mph}. Surcharge of ${result.mileage_surcharge.percentage}% applied.`, + `Mileage exceeded: TPM ${result.total_tpm} > MPM ${result.total_mpm}. Surcharge of ${result.mileage_surcharge.percentage}% applied.`, ); } if (result.hip_check.detected) { @@ -89,10 +90,10 @@ export class FareConstruction implements Agent !m.data_available); - for (const m of missingMileage) { - warnings.push(`No mileage data for ${m.origin}-${m.destination}.`); + if (result.ctm_check.missing_inputs && result.ctm_check.missing_inputs.length > 0) { + warnings.push( + `DOMAIN_INPUT_REQUIRED (CTM): ${result.ctm_check.missing_inputs.join(', ')}`, + ); } return { @@ -168,10 +169,20 @@ export class FareConstruction implements Agent; + /** + * Resolution 024d rounding rules keyed by currency. + */ + rounding_024d: Record; + /** + * Published TPM/MPM city pairs from the IATA TPM/MPM manuals. + */ + mileage: PublishedCityPairMileage[]; +} + export interface FareComponent { - /** Origin airport */ + /** Origin airport / city code */ origin: string; - /** Destination airport */ + /** Destination airport / city code */ destination: string; /** Carrier */ carrier: string; @@ -34,118 +84,121 @@ export interface FareConstructionInput { journey_type: JourneyType; /** Fare components (segments with NUC amounts) */ components: FareComponent[]; - /** Point of sale currency (ISO 4217) */ + /** Point of sale / selling currency (ISO 4217) */ selling_currency: string; - /** Point of sale country (for ROE selection) */ + /** Point of sale country (for IROE selection context) */ point_of_sale?: string; + /** + * Licensed IROE / 024d / TPM-MPM data. Required for construction. + * When omitted or incomplete, the engine returns DomainInputRequired. + */ + data_sources?: FareConstructionDataSources; } export interface MileageCheck { - /** City pair */ origin: string; destination: string; - /** Ticketed Point Mileage */ + /** Ticketed Point Mileage (published). Null when unavailable. */ tpm: number | null; - /** Maximum Permitted Mileage */ - mph: number | null; - /** Whether mileage data was found */ + /** Maximum Permitted Mileage (published). Null when unavailable. */ + mpm: number | null; + /** Whether published mileage data was found for this pair */ data_available: boolean; } +/** + * Minimal HIP sketch — comparison rules are NOT implemented. + * See docs/knowledge-base/fare-construction-data-dependencies.md. + */ export interface HipCheck { - /** Whether HIP (Higher Intermediate Point) was detected */ detected: boolean; - /** The intermediate point that triggered HIP */ hip_point: string | null; - /** HIP fare amount in NUC */ hip_nuc: string | null; - /** Description */ description: string; /** - * Set when intermediate-point fare lookup data was not provided. Each - * entry names a missing input (e.g. 'intermediate_point_fares:JFK-LON'). - * Real HIP detection requires per-airline filed fares between every - * intermediate point pair — see ATPCO Fare Construction guide. + * Missing authoritative inputs (e.g. intermediate_point_fares:JFK-LON). + * Real HIP needs per-airline filed fares between intermediate points. */ missing_inputs?: string[]; } +/** + * Minimal BHC sketch — comparison rules are NOT implemented. + */ export interface BhcCheck { - /** Whether BHC (Backhaul Check) was detected */ detected: boolean; - /** Description */ description: string; - /** - * Set when geographic-direction analysis data was not provided. Real - * BHC detection compares fare-component directionality against great- - * circle bearing of the intended journey, which requires geographic - * direction analysis beyond simple "city revisited" string matching. - */ missing_inputs?: string[]; } +/** + * Minimal CTM sketch — comparison rules are NOT implemented. + */ export interface CtmCheck { - /** Whether CTM (Circle Trip Minimum) applies */ applies: boolean; - /** CTM amount in NUC */ ctm_nuc: string | null; - /** Description */ description: string; + missing_inputs?: string[]; } export interface MileageSurcharge { - /** Whether a mileage surcharge applies */ applies: boolean; - /** Surcharge percentage (5, 10, 15, 20, 25) */ + /** EMS-style percentage band when MPM excess is computed from published TPM/MPM. */ percentage: number; - /** Surcharge amount in NUC */ surcharge_nuc: string; - /** Description */ description: string; } export interface AuditStep { - /** Step number */ step: number; - /** Step name */ name: string; - /** Description of calculation */ description: string; - /** Input value */ input: string; - /** Output value */ output: string; } export interface FareConstructionOutput { - /** Total NUC amount (sum of components + surcharges) */ total_nuc: string; - /** ROE used for conversion */ + /** IROE used for NUC → local conversion */ + iroe: string; + /** @deprecated Use `iroe`. Kept for transitional consumers. */ roe: string; - /** Local currency amount before rounding */ local_amount_raw: string; - /** Local currency amount after IATA rounding */ local_amount: string; - /** Selling currency */ currency: string; - /** Rounding rule applied */ rounding_unit: string; - /** Mileage validation per component */ + rounding_method: Rounding024dMethod; mileage_checks: MileageCheck[]; - /** Total ticketed mileage */ total_tpm: number; - /** Total maximum permitted mileage */ + total_mpm: number; + /** @deprecated Use `total_mpm`. */ total_mph: number; - /** Whether total mileage exceeds MPM */ mileage_exceeded: boolean; - /** Mileage surcharge (if applicable) */ mileage_surcharge: MileageSurcharge; - /** HIP check result */ hip_check: HipCheck; - /** BHC check result */ bhc_check: BhcCheck; - /** CTM check result (CT journeys only) */ ctm_check: CtmCheck; - /** Full audit trail */ audit_trail: AuditStep[]; } + +/** + * Requirements documents for HIP/BHC/CTM — interface sketch only. + * Engines must not invent comparison logic from these shapes alone. + */ +export interface HipCheckRequirements { + intermediate_point_fares: Array<{ + origin: string; + destination: string; + carrier: string; + nuc_amount: string; + }>; +} + +export interface BhcCheckRequirements { + /** Opaque until DOMAIN_QUESTION on published BHC rules is answered. */ + geographic_direction_analysis: unknown; +} + +export interface CtmCheckRequirements { + circle_trip_minima_nuc: Array<{ component_index: number; ctm_nuc: string }>; +} diff --git a/packages/agents/pricing/src/index.ts b/packages/agents/pricing/src/index.ts index 10f0707..2294862 100644 --- a/packages/agents/pricing/src/index.ts +++ b/packages/agents/pricing/src/index.ts @@ -24,10 +24,20 @@ export { fareRuleOutputSchema, } from './fare-rule-agent/schema.js'; -export { FareConstruction } from './fare-construction/index.js'; +export { + FareConstruction, + constructFare, + apply024dRounding, + assertPublishedTpmSource, + HAVERSINE_AS_TPM_BANNED, + HARDCODED_IROE_BANNED, + BANKERS_ROUNDING_AS_IATA_BANNED, +} from './fare-construction/index.js'; export type { FareConstructionInput, FareConstructionOutput, + FareConstructionResult, + FareConstructionDataSources, FareComponent, JourneyType, MileageCheck, @@ -36,6 +46,12 @@ export type { BhcCheck, CtmCheck, AuditStep, + Rounding024dMethod, + Rounding024dRule, + PublishedCityPairMileage, + HipCheckRequirements, + BhcCheckRequirements, + CtmCheckRequirements, } from './fare-construction/index.js'; export { TaxCalculation } from './tax-calculation/index.js'; diff --git a/packages/agents/pricing/src/tax-calculation/data/tax-rates.json b/packages/agents/pricing/src/tax-calculation/data/tax-rates.json index a2ae97d..509d440 100644 --- a/packages/agents/pricing/src/tax-calculation/data/tax-rates.json +++ b/packages/agents/pricing/src/tax-calculation/data/tax-rates.json @@ -86,7 +86,7 @@ { "type": "involuntary", "description": "Involuntary reroute or denied boarding", "exempt_from": "carrier_surcharges" } ], "currency_conversions": { - "_comment": "Simplified conversion rates to USD for tax aggregation. Use ROE data for fare conversion.", + "_comment": "TEST/DEMO ONLY — invented FX for tax aggregation. Do NOT treat as IROE. Production must ingest licensed IATA IROE (see docs/knowledge-base/fare-construction-data-dependencies.md). Missing rates should fail closed, not default to 1.0.", "USD": "1.000000", "EUR": "1.086950", "GBP": "1.260000", diff --git a/packages/agents/pricing/src/tax-calculation/tax-engine.ts b/packages/agents/pricing/src/tax-calculation/tax-engine.ts index 918b762..fd2bbc3 100644 --- a/packages/agents/pricing/src/tax-calculation/tax-engine.ts +++ b/packages/agents/pricing/src/tax-calculation/tax-engine.ts @@ -141,10 +141,19 @@ function getTaxesForCountry(country: string): TaxRule[] { function getConversionRate(from: string, to: string): Decimal { if (from === to) return new Decimal(1); + // TODO: DOMAIN_QUESTION: Tax FX must use licensed IROE (or a documented + // tax-authority rate table), not the invented currency_conversions map in + // tax-rates.json. Fail closed when the rate is missing — never silently + // return 1.0. See docs/knowledge-base/fare-construction-data-dependencies.md + // and Agent 2.2 bans (no hardcoded ROE / IROE). const fromToUsd = taxData.currency_conversions[from]; const toToUsd = taxData.currency_conversions[to]; - if (!fromToUsd || !toToUsd) return new Decimal(1); + if (!fromToUsd || !toToUsd) { + // TODO: DOMAIN_QUESTION: return DomainInputRequired instead of 1.0 once + // TaxCalculationOutput is widened like FareConstructionResult. + return new Decimal(1); + } // from -> USD -> to: amount * (fromToUsd / toToUsd) return new Decimal(fromToUsd).div(new Decimal(toToUsd)); From 1fa9abce2441fede3b80955a612d041fcbaa9151 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:05:12 +0000 Subject: [PATCH 2/4] docs(domain): split IROE (024c fare) from ICER (tax/payment FX) Domain review on #151/#159: IROE is fare construction only; ticket-tax and payment FX use ICER per IATA xrates. Tax engine must not consume IROE. Co-authored-by: telivity-otaip --- CLAUDE.md | 2 +- docs/agents/stage-2-pricing.md | 2 + .../fare-construction-data-dependencies.md | 72 ++++++++++++++----- .../src/tax-calculation/data/tax-rates.json | 2 +- .../pricing/src/tax-calculation/tax-engine.ts | 16 +++-- 5 files changed, 66 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6b7e76..73b9e9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ When building agents, Claude Code will attempt to rationalize inventing domain l | Rationalization | Required response | |---|---| | "NUC conversion uses a standard multiply-and-round pattern, I'll apply banker's rounding" | STOP. IATA Resolution **024d** uses per-currency units with **HX** (round up) or **NX** (nearest). Banker's / half-to-even is not a substitute. Check KB `docs/knowledge-base/fare-construction-data-dependencies.md`. | -| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. **IROE** values are published by IATA. Hardcoded rates produce wrong fares immediately. Pass licensed rates via `data_sources.iroe` or surface DOMAIN_INPUT_REQUIRED. Do not commit proprietary IROE files. | +| "ROE rates change periodically, I'll use a hardcoded value for now as a placeholder" | STOP. **IROE** values are published by IATA monthly for **fare construction** (Res 024c). Hardcoded rates produce wrong fares immediately. Pass licensed rates via `data_sources.iroe` or surface DOMAIN_INPUT_REQUIRED. Do not commit proprietary IROE files. Do **not** use IROE for ticket-tax / payment FX — that is **ICER** ([xrates](https://www.iata.org/en/services/finance/xrates/)). | | "TPM/MPM mileage data isn't available, I'll approximate the distance using haversine between airports" | STOP. TPM is NOT great-circle distance. TPM is published by IATA ([TPM Manual](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/)). HIP/BHC/CTM depend on exact TPM. Fail closed — never approximate. | | "The HIP check logic seems straightforward — just compare the fare to the sum of sector fares" | STOP. HIP/BHC/CTM each have different comparison rules, directionality requirements, and NUC-vs-local-currency considerations. Check KB for the exact ATPCO comparison logic before implementing. | | "For multi-sector itineraries, I'll prorate the fare evenly across segments" | STOP. IATA fare proration uses TPM-based proportional allocation, not equal division. Check KB for the exact proration formula. | diff --git a/docs/agents/stage-2-pricing.md b/docs/agents/stage-2-pricing.md index 0e2341a..09d696e 100644 --- a/docs/agents/stage-2-pricing.md +++ b/docs/agents/stage-2-pricing.md @@ -63,6 +63,8 @@ Licensed IROE / 024d / TPM-MPM data are **not** shipped in the package. Pass the Per-segment tax computation with exemption engine, ~30 countries, ~50 tax codes, currency conversion. All financial math uses `decimal.js`. +**FX note:** Ticket-tax / payment currency conversion must use licensed **ICER** (IATA Consolidated Exchange Rate, daily) — **not** IROE. IROE is fare construction only (Res 024c / Agent 2.2). See [IATA Exchange Rates](https://www.iata.org/en/services/finance/xrates/) and `docs/knowledge-base/fare-construction-data-dependencies.md`. The bundled `currency_conversions` map is demo-only; production must fail closed on missing ICER. + **Input (`TaxCalculationInput`):** - `segments` -- itinerary segments (origin/destination airports and countries, carrier, cabin class, base fare NUC) - `passenger_type` -- `'adult' | 'child' | 'infant' | 'crew' | 'diplomatic'` diff --git a/docs/knowledge-base/fare-construction-data-dependencies.md b/docs/knowledge-base/fare-construction-data-dependencies.md index 7e61f69..654b116 100644 --- a/docs/knowledge-base/fare-construction-data-dependencies.md +++ b/docs/knowledge-base/fare-construction-data-dependencies.md @@ -1,13 +1,20 @@ # Fare Construction — Data Dependencies (Agent 2.2) -Authoritative contracts for NUC × IROE construction, published TPM/MPM, -and IATA Resolution **024d** currency rounding. This document describes -**what to ingest**, not the proprietary table contents. +Authoritative contracts for NUC × IROE construction (Resolution **024c**), +published TPM/MPM, and IATA Resolution **024d** currency rounding. This +document describes **what to ingest**, not the proprietary table contents. -Do **not** commit IROE rates, 024d rounding tables, or TPM/MPM datasets to -this repository. Licensed feeds belong in deployment secrets / private -object storage and are passed into the agent at runtime via -`FareConstructionInput.data_sources`. +Do **not** commit IROE rates, ICER rates, 024d rounding tables, or TPM/MPM +datasets to this repository. Licensed feeds belong in deployment secrets / +private object storage and are passed into agents at runtime. + +**IATA exchange-rate products (do not conflate):** +[IATA Exchange Rates](https://www.iata.org/en/services/finance/xrates/) + +| Product | Cadence | Use in OTAIP | +|---|---|---| +| **IROE** (IATA Rates of Exchange) | Monthly (5-day average ending on the 10th) | **Fare / rate construction only** — NUC ↔ local under Res **024c**. Agent 2.2. | +| **ICER** (IATA Consolidated Exchange Rate) | Daily (BSR-style) | **Payment / tax / fee** conversion to alternate currencies of payment. Agent 2.3 tax aggregation and ticketing payment FX — **not** IROE. | --- @@ -15,19 +22,23 @@ object storage and are passed into the agent at runtime via 1. Construct / sum fare components in **NUC** (Neutral Unit of Construction). 2. Convert total NUC → local currency of sale / COC using **IROE** - (IATA Rate of Exchange): `local = NUC × IROE`. + (IATA Rate of Exchange, Res **024c**): `local = NUC × IROE`. 3. Apply **Resolution 024d** rounding (method **HX** or **NX**, per currency unit published in the IROE / 024d table). 4. Mileage-system checks (HIP / BHC / CTM) and MPM excess surcharges require **published TPM** (and MPM) — never great-circle / haversine substitutes. +Ticket taxes and payment-currency conversion are **out of scope for IROE**. +Those paths ingest **ICER** (see below). + --- ## Explicit bans | Ban | Why | |---|---| -| **No hardcoded IROE** in production code or shipped packages | IROE is published periodically by IATA; hardcoded rates go stale immediately and produce wrong local-currency fares. | +| **No hardcoded IROE** in production code or shipped packages | IROE is published monthly by IATA; hardcoded rates go stale immediately and produce wrong local-currency fares. | +| **No IROE as ticket-tax / payment FX** | Tax and payment conversions use **ICER**, not IROE. Do not wire Agent 2.3 (or payment) to `data_sources.iroe`. | | **No haversine / great-circle as TPM** | TPM is a published ticketed-point mileage from the IATA TPM Manual (non-stop / through scheduled services). It is **not** geodesic distance. HIP/BHC/CTM and MPM excess depend on exact published TPM. | | **No banker's rounding as “IATA rounding”** | Resolution **024d** uses per-currency units with methods **HX** (round up to next higher unit) or **NX** (round to nearest unit). IEEE banker's / half-to-even rounding is not a substitute. | | **No equal-sector proration as TPM proration** | IATA proration uses TPM-based allocation, not equal split. | @@ -35,18 +46,37 @@ object storage and are passed into the agent at runtime via --- -## Data dependency 1 — IROE (IATA Rate of Exchange) +## Data dependency 1 — IROE (IATA Rate of Exchange) — fare construction only | Field | Contract | |---|---| | **What** | Per-currency conversion factor: NUC → local currency (and inverse for published local → NUC). | -| **Source** | IATA Rates of Exchange (IROE) / Clearing House publication. Related: ICER (Consolidated Exchange Rates). | -| **Cadence** | Periodic IATA publication (subscribe / license; do not scrape or pirate). | -| **Ingestion shape** | `Record` plus optional `effective_date` / period metadata at the feed boundary. | +| **Resolution context** | Fare construction conversion under Res **024c**; rounding units/methods under Res **024d**. | +| **Source** | [IATA Rates of Exchange (IROE)](https://www.iata.org/en/services/finance/xrates/) — monthly electronic data file. | +| **Cadence** | Monthly (average of five banking days ending on the 10th). | +| **Ingestion shape** | `Record` plus optional `effective_date` / period metadata at the feed boundary (`FareConstructionInput.data_sources.iroe`). | | **Fail-closed** | If the selling currency has no IROE entry → return `DomainInputRequired` with `missing: ['iroe_table_entry:{CCY}']`. **Never** fall back to `1.0`. | +| **Not for** | Ticket-tax aggregation, payment-currency conversion, interline billing FX. | + +Contributor note: purchase / subscribe via IATA (IROE). Do not vendor the +rate file into git. + +--- + +## Data dependency — ICER (tax / payment FX) — not Agent 2.2 + +| Field | Contract | +|---|---| +| **What** | Daily exchange rates (sometimes called BSR) for converting fares, **taxes**, and **fees** to alternate currencies of payment. | +| **Source** | [IATA Consolidated Exchange Rate (ICER)](https://www.iata.org/en/services/finance/xrates/) — official industry source for international payment currency conversions used in pricing and ticketing. | +| **Cadence** | Daily electronic data file. | +| **Consumers** | Agent 2.3 Tax Calculation (and payment / ticketing FX paths). **Must not** consume IROE. | +| **Fail-closed** | Missing ICER for a currency pair → `DomainInputRequired` / halt conversion. **Never** invent rates or silently use `1.0`. | +| **Commit to git?** | **No** proprietary ICER files. | -Contributor note: purchase / subscribe via IATA publications (IROE). Do not -vendor the rate file into git. +Agent 2.3 currently still has a demo `currency_conversions` map marked +TEST/DEMO ONLY, with `DOMAIN_QUESTION` markers to ingest ICER and fail +closed. Do not “fix” that map by pointing it at IROE. --- @@ -95,6 +125,9 @@ absent: Silent approximation is a CLAUDE.md Agent 2.2 violation. +For tax / payment FX, the same fail-closed rule applies to **ICER** (Agent +2.3+) — independently of IROE. + --- ## Minimal interface sketch — HIP / BHC / CTM @@ -144,7 +177,8 @@ HIP amounts, backhaul hits, or CT minima. | Need | Where to obtain (buy / subscribe) | Commit to git? | |---|---|---| -| IROE rates | IATA Rates of Exchange (IROE); related ICER | **No** | +| IROE (fare construction / Res 024c) | [IATA Exchange Rates — IROE](https://www.iata.org/en/services/finance/xrates/) | **No** | +| ICER (tax / payment FX) | [IATA Exchange Rates — ICER](https://www.iata.org/en/services/finance/xrates/) | **No** | | 024d units + HX/NX | Resolution 024d via IATA Passenger Standards / IROE materials | **No** | | TPM city-pair mileages | [IATA TPM Manual](https://www.iata.org/en/publications/manuals/mileage/ticketed-point-mileage-tpm/) (`.txt` / API) | **No** | | MPM | IATA Maximum Permitted Mileage Manual | **No** | @@ -161,8 +195,8 @@ by the production engine module graph. ## Related code -- `packages/agents/pricing/src/fare-construction/` — Agent 2.2 +- `packages/agents/pricing/src/fare-construction/` — Agent 2.2 (IROE + 024d + TPM) +- `packages/agents/pricing/src/tax-calculation/` — Agent 2.3 (ICER for FX; not IROE) - `@otaip/core` `DomainInputRequired` / `domainInputRequired` - `CLAUDE.md` — Agent 2.2 anti-rationalization guards -- Tax calculation still has separate FX TODOs — do not reuse invented - tax `currency_conversions` as IROE +- [IATA Exchange Rates](https://www.iata.org/en/services/finance/xrates/) — IROE vs ICER product split diff --git a/packages/agents/pricing/src/tax-calculation/data/tax-rates.json b/packages/agents/pricing/src/tax-calculation/data/tax-rates.json index 509d440..2f0c01a 100644 --- a/packages/agents/pricing/src/tax-calculation/data/tax-rates.json +++ b/packages/agents/pricing/src/tax-calculation/data/tax-rates.json @@ -86,7 +86,7 @@ { "type": "involuntary", "description": "Involuntary reroute or denied boarding", "exempt_from": "carrier_surcharges" } ], "currency_conversions": { - "_comment": "TEST/DEMO ONLY — invented FX for tax aggregation. Do NOT treat as IROE. Production must ingest licensed IATA IROE (see docs/knowledge-base/fare-construction-data-dependencies.md). Missing rates should fail closed, not default to 1.0.", + "_comment": "TEST/DEMO ONLY — invented FX for tax aggregation. Do NOT treat as IROE or ICER. Ticket-tax / payment currency conversion must ingest licensed IATA ICER (daily), not IROE. IROE is fare construction only (Res 024c). See https://www.iata.org/en/services/finance/xrates/ and docs/knowledge-base/fare-construction-data-dependencies.md. Missing ICER should fail closed, not default to 1.0.", "USD": "1.000000", "EUR": "1.086950", "GBP": "1.260000", diff --git a/packages/agents/pricing/src/tax-calculation/tax-engine.ts b/packages/agents/pricing/src/tax-calculation/tax-engine.ts index fd2bbc3..200e3f9 100644 --- a/packages/agents/pricing/src/tax-calculation/tax-engine.ts +++ b/packages/agents/pricing/src/tax-calculation/tax-engine.ts @@ -141,17 +141,19 @@ function getTaxesForCountry(country: string): TaxRule[] { function getConversionRate(from: string, to: string): Decimal { if (from === to) return new Decimal(1); - // TODO: DOMAIN_QUESTION: Tax FX must use licensed IROE (or a documented - // tax-authority rate table), not the invented currency_conversions map in - // tax-rates.json. Fail closed when the rate is missing — never silently - // return 1.0. See docs/knowledge-base/fare-construction-data-dependencies.md - // and Agent 2.2 bans (no hardcoded ROE / IROE). + // TODO: DOMAIN_QUESTION: Ticket-tax / payment FX must use licensed **ICER** + // (IATA Consolidated Exchange Rate) — daily BSR-style rates for converting + // fares, taxes, and fees to alternate payment currencies. + // Do NOT use IROE here. IROE is fare-construction only (Res 024c / NUC×IROE). + // See https://www.iata.org/en/services/finance/xrates/ and + // docs/knowledge-base/fare-construction-data-dependencies.md (IROE vs ICER). + // The invented currency_conversions map in tax-rates.json is demo-only. const fromToUsd = taxData.currency_conversions[from]; const toToUsd = taxData.currency_conversions[to]; if (!fromToUsd || !toToUsd) { - // TODO: DOMAIN_QUESTION: return DomainInputRequired instead of 1.0 once - // TaxCalculationOutput is widened like FareConstructionResult. + // TODO: DOMAIN_QUESTION: fail closed with DomainInputRequired for missing + // ICER once TaxCalculationOutput is widened — never silently return 1.0. return new Decimal(1); } From 3d69d94f3745eee6ff5639414c1a017d10e13ff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 08:41:58 +0000 Subject: [PATCH 3/4] chore: regenerate agent manifest for FareConstruction 0.2.0 CI gen:manifest --check failed after Agent 2.2 version bump. Co-authored-by: telivity-otaip --- agents.graph.json | 2 +- agents.manifest.json | 2 +- docs/agent-map.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agents.graph.json b/agents.graph.json index 56d5270..3ced635 100644 --- a/agents.graph.json +++ b/agents.graph.json @@ -160,7 +160,7 @@ "id": "2.2", "name": "Fare Construction", "stage": "pricing", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/pricing/src/fare-construction/index.ts" diff --git a/agents.manifest.json b/agents.manifest.json index a01fe5b..d538898 100644 --- a/agents.manifest.json +++ b/agents.manifest.json @@ -2334,7 +2334,7 @@ "id": "2.2", "name": "Fare Construction", "stage": "pricing", - "version": "0.1.0", + "version": "0.2.0", "contract_status": "active", "has_contract": false, "source_path": "packages/agents/pricing/src/fare-construction/index.ts" diff --git a/docs/agent-map.html b/docs/agent-map.html index db149b3..f243642 100644 --- a/docs/agent-map.html +++ b/docs/agent-map.html @@ -1897,7 +1897,7 @@

Every agent, by stage.

- + - +