diff --git a/rtw/booking.py b/rtw/booking.py index 0477d38..e8ad6db 100644 --- a/rtw/booking.py +++ b/rtw/booking.py @@ -206,6 +206,27 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]: f"Flight {flight_num}: check operating carrier is IB." ) + # --- Through-flight via-stop annotation --- + via_info = "" + if seg.has_via: + via_str = ", ".join(seg.via_airports) + via_info = f"\n Through-flight via: {via_str} (book as single segment)" + warnings.append( + f"Through-flight {route} via {via_str}: must be booked as a " + f"single segment. Splitting into separate segments requires " + f"reissue and may incur fees." + ) + + # --- CX hub-connection warning --- + if seg.carrier == "CX": + from_apt = seg.from_airport + to_apt = seg.to_airport + if from_apt != "HKG" and to_apt != "HKG": + warnings.append( + f"CX {from_apt}-{to_apt}: D-class may be married through HKG. " + f"Check standalone availability via ExpertFlyer." + ) + # --- Build phone instruction --- date_str = seg.date.strftime("%d %b %Y") if seg.date else "date TBD" flight_info = f" flight {seg.flight}" if seg.flight else "" @@ -214,6 +235,8 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]: f"Segment {i + 1}: {seg.carrier}{flight_info} — {route} — {date_str}\n" f" Booking class: {booking_class}" ) + if via_info: + instruction += via_info if fj_atr_note: instruction += fj_atr_note if seg.notes: diff --git a/rtw/data/through_flights.yaml b/rtw/data/through_flights.yaml new file mode 100644 index 0000000..802eb33 --- /dev/null +++ b/rtw/data/through_flights.yaml @@ -0,0 +1,67 @@ +# Known oneworld through-flights with cross-continent impact +# Through-flight = single flight number with intermediate stop(s) +# Counts as ONE segment per Rule 3015, but ALL stop-continents count for pricing (§16) +# +# Fields: +# carrier: 2-letter IATA code +# flights: list of flight numbers +# from/to: origin/destination airport codes +# via: list of intermediate stop airports +# continents_added: continents gained from via stops (not from from/to) +# notes: optional context +# +# Only includes through-flights where via stop adds a DIFFERENT continent +# than the origin or destination (i.e., cross-continent impact). +# Through-flights where via stop is in the same continent as from/to +# (e.g., QF5/6 SYD-PER-FCO where PER=SWP same as SYD) are listed +# separately under "same_continent_via" for reference. + +cross_continent: + - carrier: QF + flights: ["QF1", "QF2"] + from: SYD + to: LHR + via: [SIN] + continents_added: [Asia] + notes: "Flagship A380 service. SIN stop adds Asia to SWP+EU_ME route." + + - carrier: BA + flights: ["BA15", "BA16"] + from: LHR + to: SYD + via: [SIN] + continents_added: [Asia] + notes: "BA's Australia service. SIN stop adds Asia to EU_ME+SWP route." + + - carrier: QR + flights: ["QR920", "QR921"] + from: DOH + to: ADL + via: [SIN] + continents_added: [Asia] + notes: "Qatar to Adelaide. SIN stop adds Asia to EU_ME+SWP route." + + - carrier: QR + flights: ["QR908", "QR909"] + from: DOH + to: MEL + via: [SIN] + continents_added: [Asia] + notes: "Qatar to Melbourne. SIN stop adds Asia to EU_ME+SWP route." + +same_continent_via: + - carrier: QF + flights: ["QF5", "QF6"] + from: SYD + to: FCO + via: [PER] + continents_added: [] + notes: "PER is SWP (same as SYD). No cross-continent impact." + + - carrier: QF + flights: ["QF3", "QF4"] + from: SYD + to: JFK + via: [AKL] + continents_added: [] + notes: "AKL is SWP (same as SYD). No cross-continent impact." diff --git a/rtw/rules/married.py b/rtw/rules/married.py new file mode 100644 index 0000000..cda27e3 --- /dev/null +++ b/rtw/rules/married.py @@ -0,0 +1,103 @@ +"""Married segment pattern detection. Based on community-reported patterns.""" + +from rtw.rules.base import register_rule +from rtw.models import RuleResult, Severity + + +@register_rule +class MarriedSegmentRule: + """Detect married segment risks from known airline patterns.""" + + rule_id = "married_segment" + rule_name = "Married Segment Detection" + rule_reference = "Community knowledge (FlyerTalk)" + + # Carriers with known hub-connection married segment patterns + _HUB_CARRIERS = { + "CX": ("HKG", "Cathay Pacific often requires HKG stopover for D-class availability"), + } + + def check(self, itinerary, context) -> list[RuleResult]: + results = [] + + # Check 1: CX hub-connection patterns + for i, seg in enumerate(itinerary.segments): + if not seg.is_flown or not seg.carrier: + continue + if seg.carrier in self._HUB_CARRIERS: + hub, reason = self._HUB_CARRIERS[seg.carrier] + from_apt = seg.from_airport + to_apt = seg.to_airport + # If neither endpoint is the hub, D-class may be married through hub + if from_apt != hub and to_apt != hub: + results.append( + RuleResult( + rule_id=self.rule_id, + rule_name=self.rule_name, + rule_reference=self.rule_reference, + passed=False, + severity=Severity.INFO, + message=( + f"Seg {i+1} {from_apt}-{to_apt} on {seg.carrier}: " + f"{reason}. D-class may only be available " + f"on connecting itineraries through {hub}." + ), + fix_suggestion=( + f"Check if adding a {hub} stopover improves D-class availability, " + f"or verify standalone availability via ExpertFlyer." + ), + segments_involved=[i], + ) + ) + + # Check 2: Through-flight split risk + # If a segment has a via stop, and that via city appears as a + # stopover destination elsewhere, splitting could trigger reissue fees. + via_cities = {} # via_airport -> segment_index + stopover_cities = set() + + for i, seg in enumerate(itinerary.segments): + if seg.has_via: + for via_apt in seg.via_airports: + via_cities[via_apt] = i + if seg.is_stopover: + stopover_cities.add(seg.to_airport) + + for via_apt, seg_idx in via_cities.items(): + if via_apt in stopover_cities: + seg = itinerary.segments[seg_idx] + results.append( + RuleResult( + rule_id=self.rule_id, + rule_name=self.rule_name, + rule_reference="Qantas AgencyConnect", + passed=False, + severity=Severity.INFO, + message=( + f"Seg {seg_idx+1} {seg.from_airport}-{seg.to_airport} " + f"is a through-flight via {via_apt}, but {via_apt} also " + f"appears as a stopover. Splitting the through-flight " + f"into separate segments may require reissue + fee." + ), + fix_suggestion=( + f"Keep {seg.from_airport}-{seg.to_airport} as a single " + f"through-flight segment. If you need a stopover in " + f"{via_apt}, book separate segments instead." + ), + segments_involved=[seg_idx], + ) + ) + + if not results: + results.append( + RuleResult( + rule_id=self.rule_id, + rule_name=self.rule_name, + rule_reference=self.rule_reference, + passed=True, + severity=Severity.INFO, + message="No married segment risks detected.", + ) + ) + + return results diff --git a/rtw/through_flights.py b/rtw/through_flights.py new file mode 100644 index 0000000..75d4c28 --- /dev/null +++ b/rtw/through_flights.py @@ -0,0 +1,47 @@ +"""Through-flight reference data loader. + +Known oneworld through-flights where intermediate stops add +cross-continent impact for pricing (Rule 3015 §16). +""" + +from pathlib import Path +from typing import Optional + +import yaml + +_DATA_PATH = Path(__file__).parent / "data" / "through_flights.yaml" +_CACHE: Optional[dict] = None + + +def load_through_flights() -> dict: + """Load through-flight reference data.""" + global _CACHE + if _CACHE is None: + with open(_DATA_PATH) as f: + _CACHE = yaml.safe_load(f) + return _CACHE + + +def get_cross_continent_flights() -> list[dict]: + """Return only through-flights with cross-continent impact.""" + data = load_through_flights() + return data.get("cross_continent", []) + + +def lookup_via_airports(carrier: str, from_apt: str, to_apt: str) -> list[str]: + """Look up known via airports for a carrier/route pair. + + Returns list of via airport codes, or empty list if not a known through-flight. + This is reference-only — users must still add `via:` explicitly to their segments. + """ + for entry in get_cross_continent_flights(): + if (entry["carrier"] == carrier + and entry["from"] == from_apt + and entry["to"] == to_apt): + return entry.get("via", []) + # Check reverse direction too + if (entry["carrier"] == carrier + and entry["from"] == to_apt + and entry["to"] == from_apt): + return entry.get("via", []) + return [] diff --git a/rtw/validator.py b/rtw/validator.py index be0af49..1a5a62e 100644 --- a/rtw/validator.py +++ b/rtw/validator.py @@ -46,6 +46,10 @@ class ValidationContext: implicit_continents: list[Continent] = field(default_factory=list) # Segments that triggered implicit continent detection {continent: [seg_indices]} implicit_continent_segments: dict[Continent, list[int]] = field(default_factory=dict) + # Via-stop continent visits (through-flight technical stops) + via_continents: list[Continent] = field(default_factory=list) + # Segments that triggered via-continent detection {continent: [(seg_index, via_airport)]} + via_continent_segments: dict[Continent, list[tuple[int, str]]] = field(default_factory=dict) def build_context(itinerary: Itinerary) -> ValidationContext: @@ -132,12 +136,43 @@ def build_context(itinerary: Itinerary) -> ValidationContext: ctx.intercontinental_arrivals = ic_arrivals ctx.intercontinental_departures = ic_departures + # Count via-stop continents (through-flight technical stops). + # Via stops affect continent pricing but NOT per-continent segment limits. + _detect_via_continents(ctx, itinerary) + # Detect implicit continent visits (e.g., Asia for EU_ME<->SWP flights) _detect_implicit_continents(ctx, itinerary) return ctx +def _detect_via_continents(ctx: ValidationContext, itinerary: Itinerary) -> None: + """Count continents from through-flight via stops (Rule 3015 §16). + + Through-flights with intermediate stops count each stop's continent + as visited for pricing, but do NOT add to per-continent segment limits + (the through-flight is still one segment). + """ + via_segments: dict[Continent, list[tuple[int, str]]] = {} + + for i, seg in enumerate(itinerary.segments): + if not seg.has_via: + continue + for via_apt in seg.via_airports: + via_cont = get_continent(via_apt) + if via_cont is None: + continue + if via_cont not in via_segments: + via_segments[via_cont] = [] + via_segments[via_cont].append((i, via_apt)) + # Add to continents_visited if not already present + if via_cont not in ctx.continents_visited: + ctx.continents_visited.append(via_cont) + + ctx.via_continent_segments = via_segments + ctx.via_continents = list(via_segments.keys()) + + def _detect_implicit_continents(ctx: ValidationContext, itinerary: Itinerary) -> None: """Detect implicit continent visits per Rule 3015 §16. @@ -193,6 +228,7 @@ def _discover_rules(self) -> None: import rtw.rules.hemisphere # noqa: F401 import rtw.rules.intercontinental # noqa: F401 import rtw.rules.country # noqa: F401 + import rtw.rules.married # noqa: F401 def validate(self, itinerary: Itinerary) -> ValidationReport: """Run all rules and return a validation report.""" diff --git a/rtw/verify/models.py b/rtw/verify/models.py index deacec6..84bd04f 100644 --- a/rtw/verify/models.py +++ b/rtw/verify/models.py @@ -135,6 +135,7 @@ class SegmentVerification(BaseModel): flight_number: Optional[str] = None target_date: Optional[datetime.date] = None dclass: Optional[DClassResult] = None + married_segment_note: Optional[str] = None class VerifyOption(BaseModel): diff --git a/rtw/verify/verifier.py b/rtw/verify/verifier.py index 02df48b..439d3ac 100644 --- a/rtw/verify/verifier.py +++ b/rtw/verify/verifier.py @@ -27,6 +27,12 @@ _CACHE_TTL_HOURS = 24 _CACHE_KEY_PREFIX = "dclass" +# Carriers with known married segment patterns (hub-connection) +_MARRIED_CHECK_HUBS = { + "CX": "HKG", + "QR": "DOH", +} + class DClassVerifier: """Verify award class availability for itinerary segments. @@ -96,6 +102,33 @@ def _store_cache(self, seg: SegmentVerification, result: DClassResult) -> None: key = self._cache_key(seg) self.cache.set(key, result.model_dump(mode="json"), ttl_hours=_CACHE_TTL_HOURS) + def _check_married_pattern( + self, seg: SegmentVerification, result: DClassResult + ) -> Optional[str]: + """Detect married segment patterns from ExpertFlyer results. + + If a carrier has a known hub and nonstop has 0 seats but + connecting flights have seats > 0, flag as likely married. + """ + carrier = seg.carrier + if not carrier or carrier not in _MARRIED_CHECK_HUBS: + return None + hub = _MARRIED_CHECK_HUBS[carrier] + # Skip if either endpoint IS the hub (not married in that case) + if seg.origin == hub or seg.destination == hub: + return None + # Check: nonstop has 0 seats but connections have seats + nonstop_seats = result.nonstop_seats + connecting_with_seats = [ + f for f in result.flights if f.stops > 0 and f.seats > 0 + ] + if nonstop_seats == 0 and connecting_with_seats: + return ( + f"{result.booking_class}-class only via connection " + f"(likely married through {hub})" + ) + return None + def verify_option( self, option: VerifyOption, @@ -174,6 +207,10 @@ def verify_option( dclass.booking_class = seg_bc verified.dclass = dclass self._store_cache(seg, dclass) + # Check for married segment pattern + verified.married_segment_note = self._check_married_pattern( + seg, dclass + ) else: verified.dclass = DClassResult( status=DClassStatus.UNKNOWN, diff --git a/specs/via-through-flights/.progress.md b/specs/via-through-flights/.progress.md new file mode 100644 index 0000000..43042c1 --- /dev/null +++ b/specs/via-through-flights/.progress.md @@ -0,0 +1,48 @@ +# via-through-flights + +## Original Goal +Add a `via` field to segments for through-flight technical stops, enabling the validator to count intermediate continent visits without consuming extra segments. Include a known through-flight lookup table and extend ImplicitAsiaRule to detect via-stop continents. + +## Interview Format +- Version: 1.0 + +## Intent Classification +- Type: GREENFIELD +- Confidence: high (4 keywords matched) +- Min questions: 5 +- Max questions: 10 +- Keywords matched: add, implement, extend, include + +## Interview Responses + +### Goal Interview (from start.md) +- Problem: Both — missing continent counting from through-flight technical stops AND segment optimization guidance for connections vs through-flights +- Constraints: Through-flight lookup auto-detection NOT wanted — only explicit `via` field triggers continent counting. Architecture decision deferred to architect for data storage location. +- Success criteria: Via field works in YAML, validator counts via-stop continents, known through-flight data available, married segment warnings with static rules + ExpertFlyer integration +- Scope: Full booking intelligence — includes married segment detection (CX/HKG, QF D-class), availability warnings, connection time analysis. Data: static known patterns + ExpertFlyer live checks when credentials configured. + +### Research Q&A +- Through-flight YAML: Simple static list (no seasonal date ranges). Seasonal awareness is a separate concern. +- Married segment severity: INFO for static pattern detection, WARNING for ExpertFlyer-confirmed married segments. +- ExpertFlyer married detection: Always check married segments during verify (user overrode opt-in recommendation). Rate limiting needs careful handling. +- Via field type: `str | list[str]` — accept both single string and list, normalize to list internally via Pydantic validator. +- Via-stop segment limits: Via stop adds continent to visited list but does NOT count toward per-continent segment limit (through-flight = one segment per Rule 3015). + +## Learnings +- Through-flight = single flight number with intermediate stop. Counts as ONE segment per Rule 3015, but ALL stop-continents count for pricing (SS16). +- Qantas AgencyConnect explicitly states through-flights must be booked as single segment; splitting requires reissue + $125 fee. +- Known cross-continent oneworld through-flights with continent impact: QF1/2 (SIN), BA15/16 (SIN), QR920/921 (SIN), QR908/909 (SIN). SIN is the dominant cross-continent via point. +- QF5/6 SYD-PER-ROM and QF3/4 SYD-AKL-JFK have via stops but NO cross-continent impact (PER=SWP, AKL=SWP). +- CX routes via HKG don't add extra continents since HKG is already Asia. +- Existing `_detect_implicit_continents()` handles nonstop EU_ME<->SWP case (e.g., QF PER-LHR). Via-stop counting is SUPPLEMENTARY, not a replacement. +- ExpertFlyer married segment detection requires paired queries (direct vs. connecting) — doubles query count. Rate limiting is a real concern. +- Married segment patterns are empirical/community knowledge, not officially documented by airlines. CX hub-connection and QF standalone-D patterns are the most commonly reported. +- AA shifted from POS to POC availability in Aug 2024, which affects how D-class availability is displayed based on where the journey starts. +- Booking.py already has basic married segment warning for same-day transit (lines 179-196). Extension point for richer warnings. +- 37 existing YAML test fixtures — all would be unaffected by optional `via: null` default field. +- Synthesis: Via-continent counting goes in build_context() BEFORE _detect_implicit_continents() so both mechanisms work independently. Via adds explicit stops; implicit handles nonstop EU_ME<->SWP flights. +- Synthesis: Separate via_continents and via_continent_segments fields on ValidationContext keep via-sourced continents distinct from implicit ones — important for debugging and display. +- Synthesis: ExpertFlyer married detection simplified — existing connection results already contain connecting flights with stops>0. No extra paired queries needed for basic detection. Just check if D-class only available on connecting flights (stops>0) but not nonstop (stops==0). +- Synthesis: MarriedSegmentRule as @register_rule follows rule engine pattern exactly. Will be rule 25 across 11 files. Discovery via explicit import in _discover_rules(). +- Synthesis: Pydantic field_validator pattern for via normalization matches existing uppercase_airports pattern on lines 145-148 of models.py. No new patterns introduced. +- Synthesis: Through-flight YAML is reference-only data. No auto-population logic. Users must add `via:` to their segments explicitly. This avoids the auto-detection complexity the user explicitly rejected. diff --git a/specs/via-through-flights/design.md b/specs/via-through-flights/design.md new file mode 100644 index 0000000..d5a350a --- /dev/null +++ b/specs/via-through-flights/design.md @@ -0,0 +1,361 @@ +--- +spec: via-through-flights +phase: design +created: 2026-02-16 +generated: auto +--- + +# Design: via-through-flights + +## Overview + +Extend the Segment model with an optional `via` field, add via-stop continent counting to the validator context builder, create a married segment rule file in the rules engine, add a through-flight reference data file, and enhance booking script + verify modules with married segment intelligence. + +## Architecture + +```mermaid +graph TB + YAML[YAML Itinerary] --> Model[Segment Model
+ via field] + Model --> Validator[build_context
+ via continent counting] + Validator --> Rules[Rule Engine] + Rules --> Married[rules/married.py
static patterns] + Model --> Booking[BookingGenerator
+ through-flight warnings] + Model --> Verify[DClassVerifier
+ paired queries] + Data[through_flights.yaml] -.-> CLI[CLI show/analyze] + Verify --> MarriedFlag[MarriedSegmentInfo
on SegmentVerification] +``` + +## Components + +### Component A: Segment Model Extension +**Purpose**: Add `via` field to Segment for through-flight technical stops +**File**: `rtw/models.py` +**Responsibilities**: +- Accept `via` as `Optional[str | list[str]]` with `None` default +- Normalize to `list[str]` via Pydantic field_validator +- Uppercase airport codes +- Expose `has_via` property and `via_airports` normalized accessor + +**Design**: +```python +class Segment(BaseModel): + # ... existing fields ... + via: Optional[str | list[str]] = None + + @field_validator("via", mode="before") + @classmethod + def normalize_via(cls, v): + if v is None: + return None + if isinstance(v, str): + return [v.upper()] + return [x.upper() for x in v] + + @property + def has_via(self) -> bool: + return self.via is not None and len(self.via) > 0 + + @property + def via_airports(self) -> list[str]: + return self.via or [] +``` + +**Backward Compatibility**: `via` defaults to `None`. All existing YAML fixtures are unaffected. The `populate_by_name` model config already handles alias resolution. + +### Component B: Validator Context Extension +**Purpose**: Count via-stop continents in `build_context()` +**File**: `rtw/validator.py` +**Responsibilities**: +- After resolving segment continents, iterate via airports and resolve their continents +- Add via-stop continents to `continents_visited` (not to `segments_per_continent`) +- Track via-stop continent sources in new `via_continents` and `via_continent_segments` context fields + +**Design**: +```python +@dataclass +class ValidationContext: + # ... existing fields ... + # Via-stop continent visits {continent: [(seg_index, via_airport)]} + via_continents: list[Continent] = field(default_factory=list) + via_continent_segments: dict[Continent, list[tuple[int, str]]] = field(default_factory=dict) +``` + +**Extension to `build_context()`**: +After the main segment loop and before `_detect_implicit_continents()`: +```python +# Count via-stop continents +via_cont_segs: dict[Continent, list[tuple[int, str]]] = {} +for i, seg in enumerate(itinerary.segments): + for via_apt in seg.via_airports: + via_cont = get_continent(via_apt) + if via_cont and via_cont not in seen_continents: + seen_continents.append(via_cont) + if via_cont: + if via_cont not in via_cont_segs: + via_cont_segs[via_cont] = [] + via_cont_segs[via_cont].append((i, via_apt)) +ctx.via_continents = list(via_cont_segs.keys()) +ctx.via_continent_segments = via_cont_segs +``` + +**Key rule**: Via-stop continents add to `continents_visited` (pricing) but NOT to `segments_per_continent` (segment limits). A through-flight remains one segment. + +### Component C: Through-Flight Reference Data +**Purpose**: Static lookup table of known oneworld through-flights +**File**: `rtw/data/through_flights.yaml` +**Responsibilities**: +- Store carrier, flight number, origin, destination, via stops, and continents added +- Serve as reference for users; NOT auto-applied to segments + +**Data Schema**: +```yaml +# Known oneworld through-flights with cross-continent via stops +# Reference data only — add `via:` to your YAML segment to trigger continent counting + +through_flights: + - carrier: QF + flights: ["QF1", "QF2"] + from: SYD + to: LHR + via: [SIN] + continents_added: [Asia] + notes: "Sydney-Singapore-London. SIN stop adds Asia to continent count." + + - carrier: BA + flights: ["BA15", "BA16"] + from: LHR + to: SYD + via: [SIN] + continents_added: [Asia] + notes: "London-Singapore-Sydney. Married segments at SIN — splitting requires reissue." + + - carrier: QR + flights: ["QR920", "QR921"] + from: DOH + to: ADL + via: [SIN] + continents_added: [Asia] + + - carrier: QR + flights: ["QR908", "QR909"] + from: DOH + to: MEL + via: [SIN] + continents_added: [Asia] + + # Same-continent via stops (no pricing impact) + - carrier: QF + flights: ["QF5", "QF6"] + from: SYD + to: FCO + via: [PER] + continents_added: [] + notes: "PER is SWP — no extra continent." + + - carrier: QF + flights: ["QF3", "QF4"] + from: SYD + to: JFK + via: [AKL] + continents_added: [] + notes: "AKL is SWP — no extra continent." +``` + +### Component D: Married Segment Rules +**Purpose**: Detect married segment risk patterns via static analysis +**File**: `rtw/rules/married.py` +**Responsibilities**: +- CX hub-connection pattern: CX segments where HKG is a connection, not an endpoint +- QF standalone long-haul: QF intercontinental without domestic QF feeder +- Through-flight split warning: via-stop city has a stopover elsewhere in itinerary +- Registered via `@register_rule` decorator + +**Design**: +```python +@register_rule +class MarriedSegmentRule: + rule_id = "married_segments" + rule_name = "Married Segment Risks" + rule_reference = "Booking Advisory" + + def check(self, itinerary, context) -> list[RuleResult]: + results = [] + results.extend(self._check_cx_hub(itinerary, context)) + results.extend(self._check_through_flight_split(itinerary)) + # Return INFO pass result if no issues found + if not results: + results.append(RuleResult( + rule_id=self.rule_id, rule_name=self.rule_name, + rule_reference=self.rule_reference, + passed=True, message="No married segment risks detected." + )) + return results +``` + +**CX Hub Pattern**: +```python +def _check_cx_hub(self, itinerary, context): + # Find CX segments where neither from nor to is HKG + # but there IS a CX-HKG connection elsewhere in itinerary + cx_has_hkg = any( + s.carrier == "CX" and (s.from_airport == "HKG" or s.to_airport == "HKG") + for s in itinerary.segments if s.is_flown + ) + results = [] + for i, seg in enumerate(itinerary.segments): + if seg.carrier == "CX" and seg.is_flown: + if seg.from_airport != "HKG" and seg.to_airport != "HKG": + results.append(RuleResult( + rule_id=self.rule_id, ..., + passed=False, severity=Severity.INFO, + message=f"CX {seg.from_airport}-{seg.to_airport}: D-class may only be " + f"available as married segment through HKG.", + segments_involved=[i], + )) + return results +``` + +**Through-Flight Split Detection**: +```python +def _check_through_flight_split(self, itinerary): + # If a segment has via stops, check if any via-stop city also has + # a stopover elsewhere — indicates user may want to split + results = [] + stopover_cities = {s.to_airport for s in itinerary.segments if s.is_stopover} + for i, seg in enumerate(itinerary.segments): + for via_apt in seg.via_airports: + if via_apt in stopover_cities: + results.append(RuleResult( + rule_id=self.rule_id, ..., + passed=False, severity=Severity.INFO, + message=f"Through-flight via {via_apt} on segment {i+1} " + f"({seg.from_airport}-{seg.to_airport}): splitting to " + f"stopover at {via_apt} converts 1 segment to 2 + $125 reissue.", + )) + return results +``` + +### Component E: Booking Script Enhancement +**Purpose**: Add through-flight and married segment warnings to phone scripts +**File**: `rtw/booking.py` +**Responsibilities**: +- Detect segments with `via` field and add through-flight annotations +- Add GDS comment for through-flights +- Enhance existing married segment warning with carrier-specific context + +**Design changes to `_segment_scripts()`**: +```python +# --- Through-flight via-stop annotation --- +if seg.has_via: + via_str = ", ".join(seg.via_airports) + from rtw.continents import get_continent + via_conts = [get_continent(v) for v in seg.via_airports] + cont_names = [c.value for c in via_conts if c] + warnings.append( + f"Through-flight via {via_str} — one segment, " + f"counts {', '.join(cont_names)} for pricing." + ) + instruction += f"\n Via: {via_str} (through-flight, single segment)" +``` + +### Component F: ExpertFlyer Married Segment Detection +**Purpose**: Compare direct vs. connection availability to detect married segments +**File**: `rtw/verify/verifier.py` (extend `DClassVerifier`) +**Responsibilities**: +- For known hub carriers (CX/HKG, QR/DOH), run paired queries during verify +- Flag segments where D-class is only available via connection +- Add `married_segment` field to `SegmentVerification` + +**Design**: +```python +# In DClassVerifier +_MARRIED_CHECK_HUBS = { + "CX": "HKG", + "QR": "DOH", +} + +async def _check_married(self, seg, direct_result) -> Optional[str]: + """Check if a segment shows married segment pattern.""" + hub = self._MARRIED_CHECK_HUBS.get(seg.carrier) + if not hub or seg.origin == hub or seg.destination == hub: + return None + # Direct has no D-class; check via hub + if direct_result.seats > 0: + return None # Direct available, no married concern + # Would need connection query via hub — but ExpertFlyer + # connection search already returns connecting flights in results + # Check if any connection flights via hub have D-class + connecting = [f for f in direct_result.flights if f.stops > 0 and f.seats > 0] + if connecting: + return f"D-class only via connection (likely married through {hub})" + return None +``` + +**New field on SegmentVerification**: +```python +class SegmentVerification(BaseModel): + # ... existing fields ... + married_segment_note: Optional[str] = None +``` + +## Data Flow + +1. User adds `via: SIN` to YAML segment +2. Pydantic normalizes to `via: ["SIN"]` on Segment model +3. `build_context()` resolves SIN -> Asia, adds to `continents_visited` +4. Rules engine runs `MarriedSegmentRule` for static pattern detection +5. `rtw verify` runs ExpertFlyer queries; paired queries detect married patterns +6. `BookingGenerator` adds through-flight annotations to phone script +7. `rtw show` displays via-stop info alongside segment details + +## Technical Decisions + +| Decision | Options | Choice | Rationale | +|----------|---------|--------|-----------| +| Via field type | `str`, `list[str]`, `str \| list[str]` | `str \| list[str]` | Convenience: single string for common case, list for rare multi-stop | +| Via normalization | Runtime helper, Pydantic validator | Pydantic field_validator | Follows existing pattern (`uppercase_airports`); normalized at parse time | +| Via continent tracking | Merge into implicit_continents, separate field | Separate `via_continents` field | Different source; keeps implicit EU_ME<->SWP rule distinct from explicit via | +| Married rule location | `booking.py`, `rules/married.py` | `rules/married.py` | Follows rule engine pattern; discoverable; testable independently | +| Through-flight data format | Python dict, JSON, YAML | YAML | Consistent with existing data files in `rtw/data/` | +| ExpertFlyer married check | Opt-in flag, always-on | Always-on | User decision: always check during verify; rate limiting handles load | + +## File Structure + +| File | Action | Purpose | +|------|--------|---------| +| `rtw/models.py` | Modify | Add `via` field + validator + properties to Segment | +| `rtw/validator.py` | Modify | Add via-continent fields to ValidationContext; extend `build_context()` | +| `rtw/data/through_flights.yaml` | Create | Known through-flight reference data | +| `rtw/rules/married.py` | Create | Married segment static pattern rules | +| `rtw/booking.py` | Modify | Through-flight and married segment warnings in phone scripts | +| `rtw/verify/models.py` | Modify | Add `married_segment_note` to SegmentVerification | +| `rtw/verify/verifier.py` | Modify | Paired query logic for married segment detection | +| `rtw/validator.py` | Modify | Register married rule module in `_discover_rules()` | +| `rtw/cli.py` | Modify | Show via-stop info in `show` and `analyze` output | +| `tests/test_models.py` | Modify | Tests for via field normalization | +| `tests/test_validator.py` | Modify | Tests for via-continent counting | +| `tests/test_rules/test_married.py` | Create | Tests for married segment rules | +| `tests/test_booking.py` | Modify | Tests for through-flight booking warnings | +| `tests/fixtures/via_through_flight.yaml` | Create | Test fixture with via stops | +| `tests/test_integration.py` | Modify | Integration test with through-flight itinerary | + +## Error Handling + +| Error | Handling | User Impact | +|-------|----------|-------------| +| Invalid via airport code | Pydantic validation error at parse time | Clear error: "via airport must be 3-letter IATA code" | +| Unknown via airport continent | `get_continent()` returns None; skip (no continent added) | Silent skip; airport still shown in output but no continent counted | +| ExpertFlyer paired query timeout | Catch timeout, return direct result only | Married detection skipped; direct availability still shown | +| ExpertFlyer rate limit hit during paired query | Skip paired query, use existing direct result | Log warning; married detection deferred | +| Through-flight YAML parse error | Fail-fast on load (same as other data files) | Startup error with clear message | + +## Existing Patterns to Follow + +- **Field validator pattern**: `rtw/models.py:145-158` — `@field_validator` with `mode="before"`, `@classmethod`, uppercase normalization +- **Optional field pattern**: `rtw/models.py:136-141` — `Optional[str] = Field(default=None)` with model_config `populate_by_name` +- **Rule registration**: `rtw/rules/geography.py:66` — `@register_rule` decorator on class with `rule_id`, `rule_name`, `rule_reference`, `check()` method +- **Rule discovery**: `rtw/validator.py:184-195` — explicit import in `_discover_rules()` +- **Data loading**: `rtw/continents.py:15-16` — `yaml.safe_load()` from `_DATA_DIR` +- **Context dataclass**: `rtw/validator.py:17-48` — `@dataclass` with `field(default_factory=list/dict)` +- **Booking warnings**: `rtw/booking.py:179-196` — append to `warnings: list[str]` in `_segment_scripts()` +- **Verify models**: `rtw/verify/models.py:127-136` — `SegmentVerification` with optional fields diff --git a/specs/via-through-flights/requirements.md b/specs/via-through-flights/requirements.md new file mode 100644 index 0000000..ef1b5c6 --- /dev/null +++ b/specs/via-through-flights/requirements.md @@ -0,0 +1,131 @@ +--- +spec: via-through-flights +phase: requirements +created: 2026-02-16 +generated: auto +--- + +# Requirements: via-through-flights + +## Summary + +Add a `via` field to itinerary segments to model through-flight technical stops, enabling the validator to count intermediate continent visits without consuming extra segments. Provide a known through-flight reference table and extend booking intelligence with married segment detection (static patterns + ExpertFlyer live checks). + +## User Stories + +### US-1: Declare through-flight via stops in YAML + +As an RTW planner, I want to add a `via` field to a segment so that the validator counts the intermediate stop's continent for pricing without adding an extra segment. + +**Acceptance Criteria**: +- AC-1.1: `via: SIN` (string) and `via: [SIN]` (list) both accepted; normalized to list internally +- AC-1.2: Via airports validated as 3-letter IATA codes, uppercased automatically +- AC-1.3: Omitting `via` field (or `via: null`) has no effect; all existing YAML fixtures remain valid +- AC-1.4: `via` field appears in `rtw show` output when present +- AC-1.5: Multiple via stops supported: `via: [SIN, KUL]` + +### US-2: Count via-stop continents for pricing + +As an RTW planner, I want via-stop continents to be counted as "visited" for continent pricing so that my ticket type matches reality (e.g., QF1 SYD-SIN-LHR counts Asia). + +**Acceptance Criteria**: +- AC-2.1: Via-stop airport resolved to continent using existing `get_continent()` function +- AC-2.2: Via-stop continent added to `continents_visited` if not already present +- AC-2.3: Via-stop does NOT count toward per-continent segment limit (through-flight = one segment) +- AC-2.4: Via-stop does NOT increment `segments_per_continent` for the via continent +- AC-2.5: Same-continent via stops (e.g., PER for SWP->SWP) have no effect on continent count +- AC-2.6: Via-stop continent counting supplements (does not replace) existing implicit EU_ME<->SWP Asia detection + +### US-3: Reference known through-flights + +As an RTW planner, I want to look up known oneworld through-flights so that I know which routes have cross-continent technical stops. + +**Acceptance Criteria**: +- AC-3.1: `rtw/data/through_flights.yaml` contains known through-flights with carrier, flight number, from, to, via, and continents_added fields +- AC-3.2: Data is reference-only; does NOT auto-populate `via` on segments (user must add `via` explicitly) +- AC-3.3: Data includes at minimum: QF1/2, BA15/16, QR920/921, QR908/909 +- AC-3.4: Data easily editable (simple YAML, no complex schema) + +### US-4: Detect married segment risks (static patterns) + +As an RTW planner, I want the validator to warn about married segment risks so that I can plan connections that are actually bookable. + +**Acceptance Criteria**: +- AC-4.1: CX hub-connection pattern detected: CX segment where neither endpoint is HKG, but itinerary has CX connection through HKG +- AC-4.2: QF standalone D-class pattern: QF long-haul segment without QF domestic connection +- AC-4.3: Through-flight split warning: segment with `via` field where via-stop city has a stopover (splitting = reissue fee) +- AC-4.4: Same-day transit warning (existing) preserved and enhanced with carrier-specific context +- AC-4.5: Static pattern warnings have severity INFO +- AC-4.6: Married segment rules registered in rule engine via `@register_rule` + +### US-5: Detect married segments via ExpertFlyer + +As an RTW planner, I want ExpertFlyer verification to always check for married segment patterns so that I get live availability intelligence. + +**Acceptance Criteria**: +- AC-5.1: During `rtw verify`, compare direct availability vs. connection availability for known hub carriers (CX/HKG, QR/DOH) +- AC-5.2: If D-class available only via connection but not standalone, flag as "married segment detected" with WARNING severity +- AC-5.3: Rate limiting: paired queries count toward daily soft limit; throttled appropriately +- AC-5.4: Married segment check runs automatically during verify (not opt-in) +- AC-5.5: Results include married segment flag on `DClassResult` or `SegmentVerification` + +### US-6: Enhanced booking script warnings + +As an RTW planner, I want the booking phone script to include through-flight and married segment warnings so the booking agent handles them correctly. + +**Acceptance Criteria**: +- AC-6.1: Through-flight segments show via-stop info: "Through-flight via {stop} -- one segment, counts {continent}" +- AC-6.2: Married segment risk segments show warning: "Married segment risk: {carrier} D-class may require connection through {hub}" +- AC-6.3: Through-flight split warning: "Stopping over at {via} converts 1 segment to 2 + $125 reissue" +- AC-6.4: GDS commands for through-flights book as single segment (no split) + +## Functional Requirements + +| ID | Requirement | Priority | Source | +|----|-------------|----------|--------| +| FR-1 | Add optional `via` field to Segment model: `Optional[str \| list[str]] = None` | Must | US-1 | +| FR-2 | Pydantic validator normalizes `via` to `list[str]`, uppercases airport codes | Must | US-1 | +| FR-3 | `build_context()` resolves via-stop continents, adds to `continents_visited` | Must | US-2 | +| FR-4 | Via-stop continents tracked separately in ValidationContext (`via_continents` field) | Must | US-2 | +| FR-5 | Via-stop does NOT affect `segments_per_continent` counts | Must | US-2 | +| FR-6 | Create `rtw/data/through_flights.yaml` with known through-flights | Should | US-3 | +| FR-7 | Create `rtw/rules/married.py` with static married segment pattern rules | Should | US-4 | +| FR-8 | Register married segment rules in validator's `_discover_rules()` | Should | US-4 | +| FR-9 | CX hub-connection married pattern detection | Should | US-4 | +| FR-10 | QF standalone D-class married pattern detection | Should | US-4 | +| FR-11 | Through-flight split detection (via-stop with stopover = reissue warning) | Should | US-4 | +| FR-12 | ExpertFlyer paired query for married segment detection during verify | Could | US-5 | +| FR-13 | `MarriedSegmentFlag` on verify results | Could | US-5 | +| FR-14 | Booking script through-flight annotations | Should | US-6 | +| FR-15 | Booking script married segment warnings | Should | US-6 | +| FR-16 | GDS commands treat through-flights as single segment | Should | US-6 | +| FR-17 | `rtw show` displays via-stop information when present | Should | US-1 | + +## Non-Functional Requirements + +| ID | Requirement | Category | +|----|-------------|----------| +| NFR-1 | All existing 1168+ tests pass with no changes (backward compatibility) | Compatibility | +| NFR-2 | All 37+ YAML fixtures remain valid without modification | Compatibility | +| NFR-3 | Through-flight data YAML loads in <10ms on startup | Performance | +| NFR-4 | No mocks for API responses in tests; fixtures and real data only | Testing | +| NFR-5 | Married segment ExpertFlyer queries respect existing rate limiting (5s between, 50/day soft limit) | Performance | +| NFR-6 | New rule file follows `@register_rule` pattern exactly | Consistency | +| NFR-7 | Via field Pydantic validator follows existing `field_validator` patterns in Segment model | Consistency | + +## Out of Scope + +- Auto-detection of through-flights from route data (user explicitly opted out) +- Seasonal date ranges on through-flight data (separate concern) +- Via field accepting airport names (IATA codes only) +- Connection time threshold refinement (existing same-day check preserved) +- Via stops contributing to per-continent segment limits +- Auto-populating `via` from through-flight lookup table + +## Dependencies + +- Existing `get_continent()` in `rtw/continents.py` for via-stop continent resolution +- Existing `@register_rule` decorator in `rtw/rules/base.py` for married segment rules +- Existing `ExpertFlyerScraper` in `rtw/scraper/expertflyer.py` for paired queries +- Existing `BookingGenerator` in `rtw/booking.py` for phone script warnings +- Pydantic v2 `field_validator` for via field normalization diff --git a/specs/via-through-flights/research.md b/specs/via-through-flights/research.md new file mode 100644 index 0000000..c34d4e2 --- /dev/null +++ b/specs/via-through-flights/research.md @@ -0,0 +1,238 @@ +--- +spec: via-through-flights +phase: research +created: 2026-02-16 +--- + +# Research: via-through-flights + +## Executive Summary + +Through-flights (single flight numbers with intermediate stops) are well-defined in aviation: they count as ONE segment but trigger continent counting at each stop. Adding a `via` field to the Segment model is technically straightforward (Optional[str | list[str]], backward compatible). Married segment detection is achievable via static pattern rules + ExpertFlyer live checks, though airline-specific married segment logic is not publicly documented in full and will require community knowledge + empirical validation. + +## External Research + +### Through-Flights: Definition and Rules + +A through-flight is "a direct flight between two points with no change in flight number. It may or may not include a stop at an intermediate point" (Qantas AgencyConnect). The FlyerTalk oneworld Explorer User Guide confirms: "a segment is a flight with a single flight number between two cities, whether or not it stops between the origin and destination." + +**Critical rule from Rule 3015 SS16**: "A continent is counted even if all you do is change planes there; even if your plane merely lands there." This means through-flight stops at intermediate airports trigger continent counting for pricing. + +**Sources**: +| Source | Key Point | +|--------|-----------| +| [Qantas AgencyConnect](https://www.qantas.com/agencyconnect/us/en/policy-and-guidelines/book-and-service/through-flight-information.html) | Through-flight = single flight number, must be booked as one segment | +| [FlyerTalk User Guide](https://www.flyertalk.com/forum/oneworld/2008084-oneworld-explorer-user-guide.html) | Segment = single flight number regardless of stops | +| [FlyerTalk FAQs p174](https://www.flyertalk.com/forum/oneworld/338667-oneworld-explorer-ticket-faqs-174.html) | BA15 LHR-SYD is one segment containing 2 married sectors with SIN stop | +| `01-fare-rules.md` SS16 (project root) | "Even technical plane stops count" for continent counting | + +### Known oneworld Through-Flights (Cross-Continent Stops) + +Compiled from flight tracking, airline sources, and community data: + +| Carrier | Flight | Route | Via Stop | Continents Crossed | +|---------|--------|-------|----------|-------------------| +| QF | QF1/QF2 | SYD-LHR | SIN | SWP -> **Asia** -> EU_ME | +| QF | QF5/QF6 | SYD-ROM | PER | SWP (no cross-continent; PER is SWP) | +| QF | QF3/QF4 | SYD-JFK | AKL | SWP -> SWP -> N_America (AKL is SWP, no extra continent) | +| BA | BA15/BA16 | LHR-SYD | SIN | EU_ME -> **Asia** -> SWP | +| QR | QR920/921 | DOH-ADL | SIN | EU_ME -> **Asia** -> SWP | +| QR | QR908/909 | DOH-MEL | SIN | EU_ME -> **Asia** -> SWP | +| CX | (various) | via HKG | HKG | Always Asia; CX hub is already in Asia | + +**Key observations**: +1. SIN is the primary cross-continent via stop (Asia) for EU_ME <-> SWP flights +2. PER and AKL stops are same-continent (SWP), so no extra continent triggered +3. CX flights via HKG don't add extra continents since HKG is already in Asia +4. QR flights DOH->SWP via SIN are the most impactful: they add Asia to continent count + +### Married Segments: Patterns and Detection + +**Definition**: Airlines link inventory on two or more flight segments so they must be booked/cancelled together. Availability may differ for married vs. individual segments. + +**Key patterns identified**: + +| Pattern | Carrier | Description | Detection Method | +|---------|---------|-------------|-----------------| +| Hub connection lock | CX | D-class often only available when connecting through HKG (not as standalone segment) | Static: CX segment where from/to != HKG, but itinerary has CX HKG connection | +| Stingy standalone D | QF | D-class rare on long-haul as standalone; more available as married with domestic connection | Static: QF transoceanic segment without QF domestic connection | +| H-class fallback | AA | Uses H class (not D) for OWE business; POS/POO rules affect availability | Static: AA segments (already handled in carriers.yaml) | +| Same-day connection | Any | Transit segments on same day have married segment risk | Static: transit + same date on consecutive segments (already in booking.py) | +| Through-flight split | BA/QF | BA15 LHR-SYD is married at SIN; splitting to stopover in SIN requires reissue ($125) | Static: known through-flight with via stop where user wants stopover | + +**Sources**: +| Source | Key Point | +|--------|-----------| +| [One Mile at a Time](https://onemileatatime.com/guides/airline-married-segment/) | Married segments = linked inventory; common on CX, QR, AA, Lufthansa | +| [AwardFares Blog](https://blog.awardfares.com/married-segments/) | Cannot "divorce" segments after booking | +| [ATPA (ANA)](https://atpa.fly-ana.com/ticketing-and-policies/married-segment-control) | Rules and penalties on married segment control | +| [AA SalesLink](https://saleslink.aa.com/en-US/resources/html/ticketing-information.html) | AA shifted to Point of Commencement availability (Aug 2024) | + +### ExpertFlyer Capabilities for Married Segment Detection + +ExpertFlyer can detect married segment patterns indirectly: +- **Connection search**: Shows availability for connecting itineraries (married inventory) +- **Individual search**: Shows standalone segment availability +- **Comparison**: If connecting shows D9 but individual shows D0, that's a married segment signal +- **Limitation**: Flight Alerts are per-segment only; cannot alert on married availability +- **Limitation**: Cannot directly query "is this a married segment?" + +**Source**: [ExpertFlyer User Guide](https://www.expertflyer.com/media/user-guide.pdf) + +### Pitfalls to Avoid + +1. **Auto-detection temptation**: User explicitly said they do NOT want through-flight auto-detection from route data. Only explicit `via` field triggers continent counting. +2. **Over-counting continents**: A via stop in the same continent as origin/destination should NOT add a new continent (e.g., QF5 via PER — both SWP). +3. **Married segment false positives**: Not all connections are married. Many carriers offer standalone D-class on long-haul. +4. **Stale through-flight data**: Airlines change through-flight routes seasonally. Data should be easy to update. + +## Codebase Analysis + +### Existing Patterns + +**Segment model** (`rtw/models.py:131-163`): Pydantic v2 BaseModel with `model_config = {"populate_by_name": True}`. Uses `Field(alias="from")` for `from_airport`. Adding optional `via` field follows exact same pattern. + +**Continent detection** (`rtw/validator.py:141-175`): `_detect_implicit_continents()` already handles EU_ME <-> SWP phantom Asia detection. Via-stop continent counting should supplement (not replace) this, since the implicit rule handles the nonstop EU_ME-SWP case (e.g., QF Perth-London nonstop still counts Asia). + +**Booking warnings** (`rtw/booking.py:179-196`): Already has basic married segment detection for same-day transit connections. This is the natural extension point for richer married segment warnings. + +**Data pattern** (`rtw/data/`): YAML files for carriers, fares, continents, hubs, same_cities. Through-flight lookup data fits this pattern perfectly as `rtw/data/through_flights.yaml`. + +**ExpertFlyer scraper** (`rtw/scraper/expertflyer.py`): Queries by O&D pair + date + carrier. To detect married segments, would need to compare results of `check_availability(A, C)` (direct) vs checking `check_availability(A, B)` + `check_availability(B, C)` (via hub). This is a meaningful extension but doubles query count. + +**Verify models** (`rtw/verify/models.py`): `FlightAvailability` already has a `stops` field. Results already distinguish nonstop vs connecting flights. `DClassResult.connection_only_segments` property exists. + +### Dependencies + +- **Pydantic v2**: Optional field with union type is straightforward: `via: Optional[str | list[str]] = None` +- **YAML parsing**: Existing YAML loader in `rtw/continents.py` can be used as pattern for through-flight data +- **airportsdata**: Already used for continent lookup; via-stop airports would use same `get_continent()` function +- **ExpertFlyer integration**: Already operational; extension needs new query patterns, not new auth/session logic + +### Constraints + +1. **Backward compatibility**: All existing YAML fixtures (37 files) lack `via` field. Optional field with `None` default ensures zero breakage. +2. **Test count**: 1168+ tests. New features must not break existing tests. +3. **No mocks for API responses**: Tests use real fixtures. Through-flight data should be testable with YAML fixtures. +4. **Rule engine architecture**: Rules are separate files registered via decorator. New married segment rules should follow this pattern. + +### Related Specs + +| Spec | Relevance | mayNeedUpdate | +|------|-----------|---------------| +| 002-dclass-verify | **High** — ExpertFlyer integration directly relevant; married segment detection extends verify logic | true | +| 004-route-builder | **Medium** — `rtw build` may want to suggest through-flights; `rtw scan-dates` could check married availability | false | +| 001-rtw-optimizer | **Low** — Core validator; this spec extends it but doesn't change fundamentals | false | +| 003-nonstop-preverify | **Low** — Nonstop check; tangential to through-flight/married segment work | false | + +## Quality Commands + +| Type | Command | Source | +|------|---------|--------| +| Lint | `ruff check rtw/ tests/` | CLAUDE.md | +| Unit Test | `uv run pytest` | pyproject.toml / CLAUDE.md | +| Unit Test (fast) | `uv run pytest -m "not slow and not integration"` | CI config | +| Build | N/A (pure Python, no build step) | - | +| TypeCheck | Not configured | - | + +**Local CI**: `ruff check rtw/ tests/ && uv run pytest -m "not slow and not integration"` + +## Feasibility Assessment + +| Aspect | Assessment | Notes | +|--------|------------|-------| +| Via field on Segment | **High** — trivial Pydantic change | Optional field, fully backward compatible | +| Continent counting from via | **High** — extends existing `_detect_implicit_continents()` | Clear algorithm: resolve via-stop continent, add to visited | +| Through-flight lookup YAML | **High** — follows existing data pattern | YAML file with carrier/flight/via mappings | +| Married segment static rules | **High** — extends existing booking.py warnings | Pattern-based: CX/HKG, QF standalone, same-day transit | +| ExpertFlyer married segment detection | **Medium** — requires paired queries | Doubles query count; rate limiting concern; empirical validation needed | +| Booking script enhancements | **High** — natural extension of existing warning system | Add through-flight notes and married segment warnings to phone script | + +| Aspect | Assessment | Notes | +|--------|------------|-------| +| Technical Viability | **High** | All components extend existing architecture cleanly | +| Effort Estimate | **M** | ~20-30 tasks across model, validator, data, booking, tests | +| Risk Level | **Low-Medium** | Main risk: married segment patterns are empirical, not documented by airlines | + +## Recommendations for Requirements + +1. **Via field type**: Use `Optional[str | list[str]] = None`. Single string for common case (one via stop), list for rare multi-stop (QF1 SYD-SIN-LHR has one via, but future routes could have two). Validator normalizes to list internally. + +2. **Supplement, don't replace implicit detection**: The existing EU_ME <-> SWP implicit Asia rule handles nonstop flights (e.g., QF PER-LHR). Via-stop counting handles through-flights. Both are needed — they cover different cases. + +3. **YAML data file for through-flights**: Create `rtw/data/through_flights.yaml` with structure: + ```yaml + QF1: + carrier: QF + from: SYD + via: [SIN] + to: LHR + continents_added: [Asia] + ``` + This is reference data only — validator uses explicit `via` field on segments, not auto-lookup. + +4. **Married segment rules as a new rule file**: Create `rtw/rules/married.py` with `MarriedSegmentRule` registered in the rule engine. This keeps it alongside other rules and follows the existing pattern. + +5. **ExpertFlyer married detection as opt-in**: When ExpertFlyer credentials are configured and `--check-married` flag is passed to `rtw verify`, run paired queries (direct vs. connecting) to detect married segment availability differences. Do NOT make this the default due to doubled query count. + +6. **Booking script warnings**: Add three new warning types: + - "Through-flight: {route} via {stop} — one segment, counts {continent} for pricing" + - "Married segment risk: {carrier} D-class may require connection through {hub}" + - "Through-flight split: Stopping over at {via} converts one segment to two + $125 reissue fee" + +## Open Questions + +1. **Via field in YAML — should it also accept airport names?** Currently the model uses 3-letter IATA codes. Should `via: Singapore` be accepted or only `via: SIN`? + +2. **Multiple through-flight stops**: QF5 SYD-PER-ROM has one via stop. Are there any oneworld through-flights with 2+ intermediate stops? Research found none currently, but the data model should support it. + +3. **Through-flight data maintenance**: Should the YAML lookup be versioned by date range (seasonal routes like QF5/6 only operate May-Sep)? Or is a simple static list sufficient? + +4. **Married segment severity**: Should married segment warnings be `WARNING` or `INFO`? They don't make the ticket invalid, but they affect bookability. + +5. **Connection time threshold**: The existing same-day transit check in `booking.py` uses `seg.date == nxt.date`. Should this be refined to use actual time analysis (e.g., <4 hours = likely married)? + +## Sources + +- [Qantas AgencyConnect: Through Flight Information](https://www.qantas.com/agencyconnect/us/en/policy-and-guidelines/book-and-service/through-flight-information.html) +- [FlyerTalk: oneworld Explorer User Guide](https://www.flyertalk.com/forum/oneworld/2008084-oneworld-explorer-user-guide.html) +- [FlyerTalk: oneworld Explorer FAQs p174](https://www.flyertalk.com/forum/oneworld/338667-oneworld-explorer-ticket-faqs-174.html) +- [One Mile at a Time: Married Segments Guide](https://onemileatatime.com/guides/airline-married-segment/) +- [AwardFares: Married Segments Guide](https://blog.awardfares.com/married-segments/) +- [AA SalesLink: Ticketing Information](https://saleslink.aa.com/en-US/resources/html/ticketing-information.html) +- [Australian Frequent Flyer: oneworld Explorer Guide](https://www.australianfrequentflyer.com.au/oneworld-explorer-rtw-guide/) +- [Point Hacks: QF1 Guide](https://www.pointhacks.com.au/guides/flights/qantas-qf1/) +- [Point Hacks: QF3 Guide](https://www.pointhacks.com.au/guides/flights/qantas-qf3/) +- [Australian Frequent Flyer: QF5 Guide](https://www.australianfrequentflyer.com.au/qf5/) +- [ExpertFlyer User Guide](https://www.expertflyer.com/media/user-guide.pdf) +- [Pydantic v2 Fields Documentation](https://docs.pydantic.dev/latest/concepts/fields/) +- Codebase: `rtw/models.py`, `rtw/validator.py`, `rtw/rules/geography.py`, `rtw/booking.py`, `rtw/continents.py`, `rtw/scraper/expertflyer.py`, `rtw/verify/models.py` + +---QUESTIONS FOR USER--- + +1. **Should the through-flight YAML include seasonal date ranges?** + - Why: QF5/6 SYD-PER-ROM only operates May-September. Static data without dates could show stale routes. + - Options: A) Simple static list (carrier/flight/via/route) B) Add `season: [May, Jun, Jul, Aug, Sep]` field C) Add `active_from/active_to` date fields + - Recommend: A — simpler, and the data is reference-only (user puts `via` on their actual segments). Seasonal awareness is a separate concern for route search. + +2. **What severity level for married segment warnings?** + - Why: Married segments don't violate Rule 3015 but affect practical bookability. + - Options: A) `WARNING` (yellow, prominent) B) `INFO` (blue, informational) C) New severity `BOOKING_RISK` (separate from validation) + - Recommend: B (INFO) for static pattern detection, A (WARNING) for ExpertFlyer-confirmed married segments. No new severity level — adds complexity without clear benefit. + +3. **Should `rtw verify --check-married` compare direct vs. connecting availability automatically?** + - Why: This doubles ExpertFlyer queries (2x per segment for connection hubs). Rate limiting + daily soft limit (50 queries) is a concern. + - Options: A) Always check married when verifying B) `--check-married` opt-in flag C) Skip ExpertFlyer married detection entirely, rely on static rules only + - Recommend: B — opt-in flag. Most users want basic D-class check; married detection is for advanced optimization. + +4. **Should the `via` field accept a single string or always require a list?** + - Why: 99% of through-flights have exactly one via stop. Requiring `via: [SIN]` for every case is verbose. + - Options: A) `str | list[str]` (accept both, normalize to list) B) Always `list[str]` C) Always `str` (single via stop only) + - Recommend: A — accept both for user convenience, normalize internally. Pydantic v2 supports this with a validator. + +5. **How should via-stop continents interact with per-continent segment limits?** + - Why: A via stop counts the continent as "visited" for pricing, but the through-flight is still ONE segment. Should it count toward the via-continent's segment limit? + - Options: A) Via stop adds continent to visited list but does NOT count toward per-continent segment limit B) Via stop counts as a segment in that continent C) Via stop counts as 0.5 segments + - Recommend: A — the through-flight is one segment (Rule 3015 is clear on this). The via stop affects pricing/continent count only, not segment limits. + +---END QUESTIONS--- diff --git a/specs/via-through-flights/tasks.md b/specs/via-through-flights/tasks.md new file mode 100644 index 0000000..46c65c4 --- /dev/null +++ b/specs/via-through-flights/tasks.md @@ -0,0 +1,133 @@ +--- +spec: via-through-flights +phase: tasks +total_tasks: 13 +created: 2026-02-16 +generated: auto +--- + +# Tasks: via-through-flights + +## Phase 1: Make It Work (POC) + +Focus: Via field on model + continent counting in validator. End-to-end proof that `via: SIN` on a segment causes Asia to appear in `continents_visited`. + +- [ ] 1.1 Add `via` field to Segment model with normalization + - **Do**: In `rtw/models.py`, add `via: Optional[str | list[str]] = None` to the `Segment` class. Add a `@field_validator("via", mode="before")` that normalizes: `None` -> `None`, `str` -> `[str.upper()]`, `list` -> `[x.upper() for x in list]`. Add `has_via` property (returns `bool`) and `via_airports` property (returns `self.via or []`). + - **Files**: `rtw/models.py` + - **Done when**: `Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"})` produces `via=["SIN"]`. `Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF"})` produces `via=None`. `Segment(**{"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["sin", "kul"]})` produces `via=["SIN", "KUL"]`. + - **Verify**: `uv run pytest tests/test_models.py -x` + - **Commit**: `feat(models): add via field to Segment for through-flight stops` + - _Requirements: FR-1, FR-2_ + - _Design: Component A_ + +- [ ] 1.2 Extend ValidationContext and build_context() for via-continent counting + - **Do**: In `rtw/validator.py`, add two new fields to `ValidationContext`: `via_continents: list[Continent]` and `via_continent_segments: dict[Continent, list[tuple[int, str]]]` (both with `default_factory`). In `build_context()`, after the main segment loop and BEFORE `_detect_implicit_continents()`, add a second loop over segments: for each segment's `via_airports`, call `get_continent(via_apt)`. If continent found and not in `seen_continents`, append it. Track in `via_continent_segments` as `(segment_index, via_airport)`. Assign to context fields. + - **Files**: `rtw/validator.py` + - **Done when**: An itinerary with segment `SYD->LHR via SIN` has `Asia` in `ctx.continents_visited` and `ctx.via_continents == [Continent.ASIA]`. An itinerary with `SYD->FCO via PER` does NOT add a new continent (PER is SWP, already counted). + - **Verify**: `uv run pytest tests/test_validator.py -x` + - **Commit**: `feat(validator): count via-stop continents in build_context` + - _Requirements: FR-3, FR-4, FR-5_ + - _Design: Component B_ + +- [ ] 1.3 POC Checkpoint — end-to-end via continent counting + - **Do**: Create test fixture `tests/fixtures/via_through_flight.yaml` with a DONE4 itinerary that includes a QF SYD-LHR segment with `via: SIN`. Verify that full validation counts Asia as visited, via-stop does NOT increment segments_per_continent for Asia, and existing tests still pass. Create `tests/test_rules/test_via_counting.py` with 3-5 targeted tests. + - **Files**: `tests/fixtures/via_through_flight.yaml`, `tests/test_rules/test_via_counting.py` + - **Done when**: New fixture validates successfully. Asia appears in continents_visited. `segments_per_continent` for Asia is 0 (or whatever the non-via count is). All existing tests pass. + - **Verify**: `uv run pytest -x` (full suite) + - **Commit**: `feat(via): complete POC — via field with continent counting` + - _Requirements: AC-2.1 through AC-2.6_ + - _Design: Components A, B_ + +## Phase 2: Core Features + +After POC validated, build out remaining features: through-flight data, married segment rules, booking warnings. + +- [ ] 2.1 Create through-flight reference data YAML + - **Do**: Create `rtw/data/through_flights.yaml` with the known through-flights from research: QF1/2 (SYD-SIN-LHR), BA15/16 (LHR-SIN-SYD), QR920/921 (DOH-SIN-ADL), QR908/909 (DOH-SIN-MEL), QF5/6 (SYD-PER-FCO, no continent impact), QF3/4 (SYD-AKL-JFK, no continent impact). Follow schema from design: carrier, flights (list), from, to, via (list), continents_added (list), notes (optional string). Add a loader function in a new `rtw/through_flights.py` module (or inline in data loading). + - **Files**: `rtw/data/through_flights.yaml`, optionally `rtw/through_flights.py` + - **Done when**: YAML file exists and is loadable with `yaml.safe_load()`. Data has at least 6 entries. Tests for data integrity (carrier codes valid, airport codes 3-letter). + - **Verify**: `python3 -c "import yaml; d=yaml.safe_load(open('rtw/data/through_flights.yaml')); print(len(d['through_flights']), 'through-flights loaded')"` + - **Commit**: `feat(data): add known through-flights reference YAML` + - _Requirements: FR-6, AC-3.1 through AC-3.4_ + - _Design: Component C_ + +- [ ] 2.2 Create married segment rules file + - **Do**: Create `rtw/rules/married.py` with `@register_rule` class `MarriedSegmentRule`. Implement three checks: (1) CX hub-connection — CX segments where neither endpoint is HKG, warn about married risk through HKG; (2) Through-flight split — segment has `via` stop and that via-stop city appears as a stopover destination elsewhere in the itinerary, warn about $125 reissue; (3) Return INFO pass result if no issues. All warnings use `Severity.INFO`. Register the module in `rtw/validator.py:_discover_rules()` by adding `import rtw.rules.married`. + - **Files**: `rtw/rules/married.py`, `rtw/validator.py` (one import line) + - **Done when**: Married rule appears in `get_registered_rules()`. CX segment NRT-SIN on a CX-heavy itinerary triggers INFO warning. Through-flight via SIN with SIN stopover elsewhere triggers split warning. Itinerary with no CX and no via fields gets "No married segment risks detected." + - **Verify**: `uv run pytest tests/test_validator.py -x && uv run pytest -x` + - **Commit**: `feat(rules): add married segment pattern detection` + - _Requirements: FR-7, FR-8, FR-9, FR-11, AC-4.1 through AC-4.6_ + - _Design: Component D_ + +- [ ] 2.3 Enhance booking script with through-flight and married segment warnings + - **Do**: In `rtw/booking.py:_segment_scripts()`, add through-flight annotation block: if `seg.has_via`, resolve via-stop continents, add warning string, and append via info to phone instruction. For GDS commands in `_gds_commands()`, through-flights should be booked as single segment (already the case — no change needed, but add a comment). Enhance existing married segment warning text with carrier-specific context when carrier is CX (mention HKG hub). + - **Files**: `rtw/booking.py` + - **Done when**: Booking script for QF SYD-LHR with `via: SIN` includes "Through-flight via SIN" in phone instructions and warnings. GDS commands still show single SS command for the segment. + - **Verify**: `uv run pytest tests/test_booking.py -x` + - **Commit**: `feat(booking): add through-flight and married segment warnings` + - _Requirements: FR-14, FR-15, FR-16, AC-6.1 through AC-6.4_ + - _Design: Component E_ + +- [ ] 2.4 Add married_segment_note to SegmentVerification and detect in verifier + - **Do**: In `rtw/verify/models.py`, add `married_segment_note: Optional[str] = None` to `SegmentVerification`. In `rtw/verify/verifier.py`, after checking direct availability for a segment, check if the carrier is in `_MARRIED_CHECK_HUBS` dict (`{"CX": "HKG", "QR": "DOH"}`). If direct result has `seats == 0` but `flights` list contains connecting flights with seats > 0, set `married_segment_note` on the SegmentVerification. This uses existing ExpertFlyer results (no extra queries needed for the basic version). + - **Files**: `rtw/verify/models.py`, `rtw/verify/verifier.py` + - **Done when**: SegmentVerification has `married_segment_note` field. CX segment with 0 nonstop D-class but connecting D-class available gets note "D-class only via connection (likely married through HKG)". + - **Verify**: `uv run pytest tests/test_verify_models.py -x` + - **Commit**: `feat(verify): detect married segment patterns from ExpertFlyer results` + - _Requirements: FR-12, FR-13, AC-5.1, AC-5.2, AC-5.5_ + - _Design: Component F_ + +## Phase 3: Testing + +- [ ] 3.1 Unit tests for via field and continent counting + - **Do**: In `tests/test_models.py`, add tests for: via=None (default), via="sin" (normalized to ["SIN"]), via=["sin","kul"] (normalized to ["SIN","KUL"]), via_airports property, has_via property, invalid via (non-string elements). In `tests/test_rules/test_via_counting.py`, add tests for: via-stop adds continent, via-stop same continent no effect, multiple via stops, via-stop does NOT increment segments_per_continent, via-stop combined with implicit Asia rule (both should work independently). + - **Files**: `tests/test_models.py`, `tests/test_rules/test_via_counting.py` + - **Done when**: At least 10 new tests pass covering all acceptance criteria from US-1 and US-2. + - **Verify**: `uv run pytest tests/test_models.py tests/test_rules/test_via_counting.py -v` + - **Commit**: `test(via): add unit tests for via field and continent counting` + - _Requirements: AC-1.1 through AC-1.5, AC-2.1 through AC-2.6_ + +- [ ] 3.2 Unit tests for married segment rules + - **Do**: Create `tests/test_rules/test_married.py`. Test scenarios: (1) CX segment not through HKG triggers INFO warning, (2) CX segment through HKG does NOT trigger warning, (3) through-flight split: segment with via SIN + SIN stopover elsewhere triggers warning, (4) through-flight no split: via SIN but no SIN stopover, (5) no CX no via = pass result, (6) multiple CX segments produce multiple warnings. + - **Files**: `tests/test_rules/test_married.py` + - **Done when**: At least 6 tests covering all married segment patterns pass. + - **Verify**: `uv run pytest tests/test_rules/test_married.py -v` + - **Commit**: `test(married): add unit tests for married segment rules` + - _Requirements: AC-4.1 through AC-4.6_ + +- [ ] 3.3 Unit tests for booking script through-flight warnings + - **Do**: In `tests/test_booking.py`, add tests for: (1) segment with via produces through-flight warning in phone script, (2) segment with via shows via info in instruction text, (3) segment without via has no through-flight warning, (4) GDS command for via segment is single SS entry (not split). + - **Files**: `tests/test_booking.py` + - **Done when**: At least 4 new booking tests pass. + - **Verify**: `uv run pytest tests/test_booking.py -v` + - **Commit**: `test(booking): add through-flight warning tests` + - _Requirements: AC-6.1 through AC-6.4_ + +- [ ] 3.4 Integration test with through-flight itinerary + - **Do**: In `tests/test_integration.py` or `tests/test_new_fixtures.py`, add a test that loads the `via_through_flight.yaml` fixture, runs full validation, and asserts: (1) validation passes, (2) continents_visited includes the via-stop continent, (3) married segment rule produces results, (4) booking script includes through-flight annotations. + - **Files**: `tests/test_integration.py` or `tests/test_new_fixtures.py` + - **Done when**: Integration test exercises full pipeline: YAML -> model -> validator -> rules -> booking for a through-flight itinerary. + - **Verify**: `uv run pytest tests/test_integration.py -v -k via` + - **Commit**: `test(integration): add through-flight end-to-end test` + +## Phase 4: Quality Gates + +- [ ] 4.1 Local quality check + - **Do**: Run full test suite, lint check, and verify no regressions. Ensure all 1168+ existing tests still pass plus new tests. Run `ruff check` on all modified files. + - **Verify**: `ruff check rtw/ tests/ && uv run pytest -x` + - **Done when**: All commands pass with 0 errors. Total test count has increased. + - **Commit**: `fix(via): address lint/type issues` (if needed) + +- [ ] 4.2 Create PR and verify CI + - **Do**: Push branch, create PR with gh CLI. PR title: "feat: add via field for through-flight stops + married segment detection". PR body: summary of changes, link to spec, test plan. + - **Verify**: `gh pr checks --watch` all green + - **Done when**: PR ready for review with all CI checks passing. + +## Notes + +- **POC shortcuts taken**: Phase 1 skips married rules, booking warnings, and ExpertFlyer integration. Just model + validator. +- **Production TODOs**: ExpertFlyer paired query for married detection uses existing connection results (no extra queries). Full paired query implementation deferred if basic detection is sufficient. +- **Backward compatibility**: All changes are additive. `via: null` default means zero impact on existing YAML files. +- **Rule count**: After adding married.py, total rules will be 25 across 11 files. diff --git a/tests/fixtures/via_through_flight.yaml b/tests/fixtures/via_through_flight.yaml new file mode 100644 index 0000000..ec35c6d --- /dev/null +++ b/tests/fixtures/via_through_flight.yaml @@ -0,0 +1,66 @@ +# Through-flight via-stop test: DONE5 business from LHR +# Tests that a through-flight via: SIN counts Asia from via stop +# without consuming an extra segment. +# +# Route: LHR -> NRT -> SYD (via SIN) -> LAX -> GRU -> JFK -> LHR (eastbound) +# Key: NRT-SYD segment has via: SIN (through-flight technical stop in Singapore) +# Continents: EU_ME, Asia (NRT + reinforced by via SIN), SWP, N_America, S_America = 5 +# Via-stop does NOT add to segments_per_continent for Asia +# 6 segments, all within limits + +ticket: + type: DONE5 + cabin: business + origin: LHR + passengers: 1 + departure: "2026-04-01" + +segments: + # EU_ME -> Asia (TC2 -> TC3) + - from: LHR + to: NRT + carrier: JL + date: "2026-04-01" + type: stopover + notes: "JAL to Tokyo" + + # Asia -> SWP (within TC3) — through-flight via SIN + - from: NRT + to: SYD + carrier: QF + date: "2026-04-05" + type: stopover + via: SIN + notes: "Qantas to Sydney — through-flight via Singapore" + + # SWP -> N_America (TC3 -> TC1 — Pacific crossing) + - from: SYD + to: LAX + carrier: QF + date: "2026-04-10" + type: stopover + notes: "Qantas A380 — Pacific crossing" + + # N_America -> S_America (within TC1) + - from: LAX + to: GRU + carrier: AA + date: "2026-04-15" + type: stopover + notes: "AA to Sao Paulo" + + # S_America -> N_America (within TC1) + - from: GRU + to: JFK + carrier: AA + date: "2026-04-20" + type: stopover + notes: "AA return to JFK" + + # N_America -> EU_ME (TC1 -> TC2 — Atlantic crossing) + - from: JFK + to: LHR + carrier: BA + date: "2026-04-25" + type: final + notes: "BA Club World — Atlantic crossing, return to origin" diff --git a/tests/test_booking.py b/tests/test_booking.py index b7c9d08..b46594d 100644 --- a/tests/test_booking.py +++ b/tests/test_booking.py @@ -195,6 +195,92 @@ def test_mex_mad_ib_also_flagged(self, generator, v3): assert len(ib_warnings) >= 1 +class TestThroughFlightWarnings: + """Test through-flight via-stop annotations in booking script.""" + + def test_via_produces_through_flight_warning(self, generator): + """Segment with via produces through-flight warning.""" + itin = Itinerary( + ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"}, + segments=[ + {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN"}, + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "LAX", "carrier": "JL"}, + {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"}, + ], + ) + scripts = generator._segment_scripts(itin) + via_warnings = [w for w in scripts[0].warnings if "Through-flight" in w] + assert len(via_warnings) == 1 + assert "SIN" in via_warnings[0] + assert "single segment" in via_warnings[0] + + def test_via_shows_in_phone_instruction(self, generator): + """Via info appears in phone instruction text.""" + itin = Itinerary( + ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"}, + segments=[ + {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN"}, + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "LAX", "carrier": "JL"}, + {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"}, + ], + ) + scripts = generator._segment_scripts(itin) + assert "Through-flight via: SIN" in scripts[0].phone_instruction + + def test_no_via_no_through_flight_warning(self, generator): + """Segment without via has no through-flight warning.""" + itin = Itinerary( + ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"}, + segments=[ + {"from": "SYD", "to": "LHR", "carrier": "QF"}, + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "LAX", "carrier": "JL"}, + {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final"}, + ], + ) + scripts = generator._segment_scripts(itin) + via_warnings = [w for w in scripts[0].warnings if "Through-flight" in w] + assert len(via_warnings) == 0 + + def test_gds_via_segment_single_entry(self, generator): + """GDS command for via segment is a single SS entry (not split).""" + itin = Itinerary( + ticket={"type": "DONE4", "cabin": "business", "origin": "SYD"}, + segments=[ + {"from": "SYD", "to": "LHR", "carrier": "QF", "via": "SIN", + "flight": "QF1", "date": "2026-04-01"}, + {"from": "LHR", "to": "NRT", "carrier": "JL", + "date": "2026-04-05"}, + {"from": "NRT", "to": "LAX", "carrier": "JL", + "date": "2026-04-10"}, + {"from": "LAX", "to": "SYD", "carrier": "QF", "type": "final", + "date": "2026-04-15"}, + ], + ) + gds = generator._gds_commands(itin) + # First SS command should be SYD-LHR, not SYD-SIN + SIN-LHR + ss_commands = [c for c in gds if c.startswith("SS")] + assert any("SYDLHR" in c for c in ss_commands) + assert not any("SYDSIN" in c for c in ss_commands) + + def test_cx_booking_warning(self, generator): + """CX segment not through HKG triggers D-class married warning.""" + itin = Itinerary( + ticket={"type": "DONE4", "cabin": "business", "origin": "LHR"}, + segments=[ + {"from": "LHR", "to": "NRT", "carrier": "CX"}, + {"from": "NRT", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ], + ) + scripts = generator._segment_scripts(itin) + cx_warnings = [w for w in scripts[0].warnings if "CX" in w and "HKG" in w] + assert len(cx_warnings) == 1 + + # --- T033: GDS commands --- diff --git a/tests/test_models.py b/tests/test_models.py index ee7b980..c30d983 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -101,6 +101,34 @@ def test_default_type_is_stopover(self): s = Segment(**{"from": "CAI", "to": "AMM"}) assert s.type == SegmentType.STOPOVER + def test_via_default_none(self): + s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF"}) + assert s.via is None + assert s.has_via is False + assert s.via_airports == [] + + def test_via_string_normalized(self): + s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"}) + assert s.via == ["SIN"] + assert s.has_via is True + assert s.via_airports == ["SIN"] + + def test_via_list_normalized(self): + s = Segment(**{"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["sin", "kul"]}) + assert s.via == ["SIN", "KUL"] + assert s.has_via is True + assert s.via_airports == ["SIN", "KUL"] + + def test_via_empty_list(self): + s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": []}) + assert s.has_via is False + assert s.via_airports == [] + + def test_via_preserved_in_model_dump(self): + s = Segment(**{"from": "SYD", "to": "LHR", "carrier": "QF", "via": "sin"}) + d = s.model_dump(mode="json") + assert d["via"] == ["SIN"] + # --- Itinerary Tests --- diff --git a/tests/test_rules/test_fixture_validation.py b/tests/test_rules/test_fixture_validation.py index b5208e5..54bf530 100644 --- a/tests/test_rules/test_fixture_validation.py +++ b/tests/test_rules/test_fixture_validation.py @@ -329,3 +329,36 @@ def test_rule_implicit_asia(self, validator): # SWP, EU_ME, N_America, Asia (implicit or explicit via NRT) continent_results = _results_by_rule(report, "continent_count") assert all(r.passed for r in continent_results) + + +# --------------------------------------------------------------------------- +# Via Through-Flight Fixtures +# --------------------------------------------------------------------------- + +class TestViaFixtures: + """Tests for through-flight via-stop counting.""" + + def test_via_through_flight_full_pipeline(self, validator): + """via_through_flight.yaml: DONE5 with via: SIN on NRT-SYD. + Asia counted from NRT segment + reinforced by via SIN. + Married segment rule produces result (no CX, no via-split risk). + 5 continents -> DONE5 passes continent count. + """ + report = validator.validate(_load("via_through_flight.yaml")) + + # Continent count passes (5 continents) + continent_results = _results_by_rule(report, "continent_count") + assert all(r.passed for r in continent_results) + + # Married segment rule fires (pass = no risks detected) + married_results = _results_by_rule(report, "married_segment") + assert len(married_results) >= 1 + # No CX, no via-split risk -> should pass + assert all(r.passed for r in married_results) + + # Return to origin: LHR->LHR + return_results = _results_by_rule(report, "return_to_origin") + assert all(r.passed for r in return_results) + + # Overall should pass (no violations) + assert report.passed diff --git a/tests/test_rules/test_married.py b/tests/test_rules/test_married.py new file mode 100644 index 0000000..04239ec --- /dev/null +++ b/tests/test_rules/test_married.py @@ -0,0 +1,100 @@ +"""Tests for MarriedSegmentRule — CX hub-connection and through-flight split detection.""" + +from rtw.models import Itinerary, Ticket, Segment, Severity +from rtw.rules.married import MarriedSegmentRule + + +def _make_itinerary(segments_data, origin="LHR", ticket_type="DONE4"): + ticket = Ticket(type=ticket_type, cabin="business", origin=origin) + segments = [Segment(**s) for s in segments_data] + return Itinerary(ticket=ticket, segments=segments) + + +class TestCXHubConnection: + def test_cx_not_through_hkg_warns(self): + """CX NRT-SIN (neither is HKG) triggers married segment INFO.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SIN", "carrier": "CX"}, # Neither is HKG + {"from": "SIN", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + info = [r for r in results if not r.passed and r.severity == Severity.INFO] + assert len(info) >= 1 + assert "CX" in info[0].message + assert "HKG" in info[0].message + + def test_cx_through_hkg_no_warning(self): + """CX HKG-SYD (one endpoint is HKG) does NOT trigger warning.""" + segs = [ + {"from": "LHR", "to": "HKG", "carrier": "CX"}, + {"from": "HKG", "to": "SYD", "carrier": "CX"}, # HKG is endpoint + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + cx_warnings = [r for r in results if not r.passed and "CX" in r.message] + assert len(cx_warnings) == 0 + + def test_multiple_cx_segments(self): + """Multiple CX segments not through HKG produce multiple warnings.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "CX"}, # Neither is HKG + {"from": "NRT", "to": "SIN", "carrier": "CX"}, # Neither is HKG + {"from": "SIN", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + cx_warnings = [r for r in results if not r.passed and "CX" in r.message] + assert len(cx_warnings) == 2 + + +class TestThroughFlightSplit: + def test_via_stop_city_as_stopover_warns(self): + """Via SIN + SIN as stopover destination triggers split warning.""" + segs = [ + {"from": "LHR", "to": "SIN", "carrier": "BA"}, # Stopover in SIN + {"from": "SIN", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"}, # Via SIN! + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + split_warnings = [r for r in results if not r.passed and "through-flight" in r.message] + assert len(split_warnings) == 1 + assert "SIN" in split_warnings[0].message + + def test_via_stop_no_matching_stopover_ok(self): + """Via SIN but no SIN stopover elsewhere — no split warning.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + split_warnings = [r for r in results if not r.passed and "through-flight" in r.message] + assert len(split_warnings) == 0 + + +class TestNoIssues: + def test_no_cx_no_via_passes(self): + """No CX and no via fields — clean pass.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + results = MarriedSegmentRule().check(itin, None) + assert len(results) == 1 + assert results[0].passed is True + assert "No married segment risks" in results[0].message diff --git a/tests/test_rules/test_via_counting.py b/tests/test_rules/test_via_counting.py new file mode 100644 index 0000000..ded25ba --- /dev/null +++ b/tests/test_rules/test_via_counting.py @@ -0,0 +1,182 @@ +"""Tests for via-stop continent counting in build_context. + +Via stops are through-flight technical stops (Rule 3015 §16). +They count the stop's continent as visited for pricing, but do NOT +add to per-continent segment limits (through-flight = one segment). +""" + +import yaml +from pathlib import Path + +from rtw.models import Itinerary, Ticket, Segment, Continent +from rtw.validator import build_context + + +def _make_itinerary(segments_data, origin="LHR", ticket_type="DONE4"): + ticket = Ticket(type=ticket_type, cabin="business", origin=origin) + segments = [Segment(**s) for s in segments_data] + return Itinerary(ticket=ticket, segments=segments) + + +class TestViaContinentCounting: + """Tests for _detect_via_continents in build_context.""" + + def test_via_adds_new_continent(self): + """Via stop in a new continent adds it to continents_visited.""" + # Route: SYD -> LAX (via SIN) -> GRU -> JFK -> SYD + # Without via: SWP, N_America, S_America = 3 + # With via SIN: SWP, N_America, S_America, Asia = 4 + segs = [ + {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"}, + {"from": "LAX", "to": "GRU", "carrier": "AA"}, + {"from": "GRU", "to": "JFK", "carrier": "AA"}, + {"from": "JFK", "to": "SYD", "carrier": "QF", "type": "final"}, + ] + itin = _make_itinerary(segs, origin="SYD") + ctx = build_context(itin) + assert Continent.ASIA in ctx.continents_visited + assert Continent.ASIA in ctx.via_continents + + def test_via_same_continent_no_effect(self): + """Via stop in an already-visited continent doesn't duplicate.""" + # NRT->SYD via SIN: SIN is Asia, same as NRT departure + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + ctx = build_context(itin) + # Asia should appear exactly once in continents_visited + asia_count = ctx.continents_visited.count(Continent.ASIA) + assert asia_count == 1 + # But via_continents should still record it + assert Continent.ASIA in ctx.via_continents + + def test_via_does_not_increment_segments_per_continent(self): + """Via stop does NOT add to segments_per_continent.""" + # SYD->LAX via SIN: Asia counted via stop, not as a segment + segs = [ + {"from": "SYD", "to": "LAX", "carrier": "QF", "via": "SIN"}, + {"from": "LAX", "to": "GRU", "carrier": "AA"}, + {"from": "GRU", "to": "JFK", "carrier": "AA"}, + {"from": "JFK", "to": "SYD", "carrier": "QF", "type": "final"}, + ] + itin = _make_itinerary(segs, origin="SYD") + ctx = build_context(itin) + # Asia should have 0 segments (only counted via via-stop) + assert ctx.segments_per_continent.get(Continent.ASIA, 0) == 0 + + def test_via_continent_segments_tracking(self): + """via_continent_segments records which segment and airport triggered detection.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SYD", "carrier": "QF", "via": "SIN"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + ctx = build_context(itin) + assert Continent.ASIA in ctx.via_continent_segments + entries = ctx.via_continent_segments[Continent.ASIA] + assert len(entries) == 1 + seg_idx, via_apt = entries[0] + assert seg_idx == 1 # Second segment (NRT->SYD) + assert via_apt == "SIN" + + def test_multiple_via_stops(self): + """Multiple via stops on a single segment all counted.""" + # Hypothetical: DOH->ADL via SIN,KUL + segs = [ + {"from": "LHR", "to": "DOH", "carrier": "QR"}, + {"from": "DOH", "to": "ADL", "carrier": "QR", "via": ["SIN", "KUL"]}, + {"from": "ADL", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + ctx = build_context(itin) + assert Continent.ASIA in ctx.via_continents + entries = ctx.via_continent_segments[Continent.ASIA] + # Both SIN and KUL are Asia, so two entries + assert len(entries) == 2 + airports = [e[1] for e in entries] + assert "SIN" in airports + assert "KUL" in airports + + def test_via_works_alongside_implicit_asia(self): + """Via counting and implicit Asia detection work independently.""" + # DOH->SYD triggers implicit Asia, NRT->SYD has via: SIN + segs = [ + {"from": "LHR", "to": "DOH", "carrier": "QR"}, + {"from": "DOH", "to": "SYD", "carrier": "QR"}, # Implicit Asia + {"from": "SYD", "to": "NRT", "carrier": "QF"}, + {"from": "NRT", "to": "LAX", "carrier": "JL", "via": "SIN"}, # Via Asia + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + ctx = build_context(itin) + # Both mechanisms should detect Asia + assert Continent.ASIA in ctx.implicit_continents + assert Continent.ASIA in ctx.via_continents + # But only one entry in continents_visited + assert ctx.continents_visited.count(Continent.ASIA) == 1 + + def test_no_via_field_no_via_continents(self): + """Segments without via field produce empty via_continents.""" + segs = [ + {"from": "LHR", "to": "NRT", "carrier": "JL"}, + {"from": "NRT", "to": "SYD", "carrier": "QF"}, + {"from": "SYD", "to": "LAX", "carrier": "QF"}, + {"from": "LAX", "to": "LHR", "carrier": "BA", "type": "final"}, + ] + itin = _make_itinerary(segs) + ctx = build_context(itin) + assert ctx.via_continents == [] + assert ctx.via_continent_segments == {} + + +class TestViaFixtureIntegration: + """Integration test using the via_through_flight.yaml fixture.""" + + def test_fixture_loads_and_validates(self): + """via_through_flight.yaml loads correctly and passes validation.""" + fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml" + with open(fixture_path) as f: + data = yaml.safe_load(f) + itin = Itinerary(**data) + ctx = build_context(itin) + + # 5 continents: EU_ME, Asia, SWP, N_America, S_America + assert len(ctx.continents_visited) == 5 + assert Continent.EU_ME in ctx.continents_visited + assert Continent.ASIA in ctx.continents_visited + assert Continent.SWP in ctx.continents_visited + assert Continent.N_AMERICA in ctx.continents_visited + assert Continent.S_AMERICA in ctx.continents_visited + + def test_fixture_via_detected(self): + """Fixture's via: SIN is detected in via_continent_segments.""" + fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml" + with open(fixture_path) as f: + data = yaml.safe_load(f) + itin = Itinerary(**data) + ctx = build_context(itin) + + assert Continent.ASIA in ctx.via_continents + entries = ctx.via_continent_segments[Continent.ASIA] + assert any(apt == "SIN" for _, apt in entries) + + def test_fixture_via_no_segment_count(self): + """Via stop doesn't inflate segments_per_continent.""" + fixture_path = Path(__file__).parent.parent / "fixtures" / "via_through_flight.yaml" + with open(fixture_path) as f: + data = yaml.safe_load(f) + itin = Itinerary(**data) + ctx = build_context(itin) + + # Asia segments: LHR->NRT counts (intra-continental would be 0 since + # it's intercontinental TC2->TC3). Via SIN should NOT add any. + # The key: via_continent_segments records via, but segments_per_continent doesn't. + via_asia_count = len(ctx.via_continent_segments.get(Continent.ASIA, [])) + assert via_asia_count >= 1 # At least the via: SIN detection