Docs / TEOScore / Scoring engine
How TeoScore ranks a flight
The as-built reference for the scoring engine: the pipeline that turns a search into a 0–100 score, how the eight categories are weighted, and what every variable (V1–V21) actually computes. Where the narrative spec and the code disagree, this page follows the code.
Is this real data? What’s measured vs estimated
TeoScore blends real airline/provider data with estimates and inferences. Here’s the honest breakdown for every variable, in plain English, so you know what to trust. When a variable has no data it either drops out or sits at a neutral 50 — it doesn’t invent a number.
- 🟢 Real — straight from a provider or the itinerary
- 🟢🟡 Mixed — part real, part estimate
- 🟡 Estimated — a curated table or heuristic (a best guess)
- 🟡 Inferred — worked out indirectly, not told to us
- ⚪ Not scored yet — usually no data, so it stays neutral / off
Tells you: Which option costs least, in its own currency
What we do: Rank this price between the cheapest and priciest options in your results — cheapest ≈ 100. Cash is ranked against cash, award points against award points.
Under the hood: scoreCategoryEconomicValue: min-max against the search’s cash-price range for cash options, and against its award-points range for award options. No fixed-curve fallback — without a search-relative range V1 drops.
Limitation: Straight from the providers’ cash prices and published points costs. Award points are compared as published, never converted between programmes; V2’s cents per point is the common-unit measure.
Tells you: How good the points value is
What we do: Work out cents of value per point (cash value ÷ points); ~2¢/pt or better ≈ 100.
Under the hood: resolveCashValueLadder finds a comparable cash fare (exact → same-day → market median); CPP tiered. Taxes never estimated.
Limitation: The award price is real; the cash price it’s compared against is often a same-day market estimate.
Tells you: Miles you’d earn
What we do: Multiply miles earned by their cash value and compare to the best earner in your results.
Under the hood: miles × getPMVForProgram (per-program value) vs the search’s bestEarningsValueCents.
Limitation: Real miles × a per-program cash-value estimate.
Tells you: How long the trip takes
What we do: Compare total travel time to the fastest option in your results; longer = lower.
Under the hood: Benchmark-relative to the search’s shortest elapsed/air time; V4b (elapsed) 60% + V4c (air) 40%. V4a door-to-door always drops.
Limitation: From the actual schedule.
Tells you: Matches the time you asked for
What we do: Measure how many minutes the departure/arrival is off your requested time; further off = lower.
Under the hood: computeV5Score: minute deviation from preferredDepartureTime/ArrivalTime; needs searchPreferences plumbed through the pipeline.
Limitation: Real times — but only active when you set a preferred time (and it’s deployed); otherwise off.
Tells you: Red-eye penalty
What we do: Subtract a penalty for red-eye departures; waived if you explicitly searched for one.
Under the hood: Red-eye penalty only for now (−20, waived on intent). The timezone/jet-lag penalty is built and tested but gated off pending a launch decision (V6_JET_LAG_DISABLED).
Limitation: Red-eye detection is real. Jet lag isn’t scored yet, even though the underlying timezone data now exists.
Tells you: Number of stops
What we do: Compare stops to the most direct option; each extra stop drops the score.
Under the hood: max(0, 100 − 25 × (thisStops − fewestStopsInSearch)).
Limitation: Counted from the itinerary.
Tells you: Is the layover the right length
What we do: Judge each layover against a minimum connection time — too short is risky, too long is tedious; the worst connection sets the score.
Under the hood: MCT-anchored curve per connection; MCT from a curated hub table (getAirportMct) → 90/120 default; min across connections. Modes: minimize/extended/stopover.
Limitation: Layover time is real; the minimum-connection-time it’s judged against is our estimate (not official airline MCT). “Extended/stopover” modes aren’t wired to search yet.
Tells you: How hard the connection is (currently disabled)
What we do: Would score each connection’s terminal change (same terminal easy → different airport hardest); the worst one wins.
Under the hood: Disabled (V9_DISABLED, 2026-08-04): seats.aero award itineraries have no terminal data at all, so the whole variable was turned off rather than just for award. Logic intact: computeV9's cascading tiers from an airside-connectivity map, carrier-coherence fallback when no terminal data, min across connections.
Limitation: Not scored today. When re-enabled: real terminals when known (cash only), otherwise a same-airline/alliance guess.
Tells you: One ticket vs self-transfer (currently disabled)
What we do: Would score how protected the connection is — single ticket safest (100), self-transfer worst (0).
Under the hood: Disabled (V10_DISABLED, 2026-08-04): explicit product decision, no specific data/scope gap recorded. Logic intact: inferBookingProtectionTier (web layer) sets single_ticket if all legs share one airline; computeV10 maps tier→score.
Limitation: Not scored today. When re-enabled: NOT from the airline directly — inferred from whether all legs share one airline. Mixed-carrier tiers are incomplete.
Tells you: Rebooking odds if cancelled
What we do: Count same-day alternative flights to your destination — same-airline and soon-after-original options count more (easier rebooking).
Under the hood: computeV11FromSearch (fixed 2026-08-04): the other options in this same search, on your exact route, arriving the same local day — no separate provider feed. min(100, Base_Floor + Σ Time_Weight×Carrier_Weight).
Limitation: Real for popular routes with multiple options in the search; thin routes with only one option per day correctly floor low or fall to neutral, not a data gap.
Tells you: Refund / change rules (currently disabled)
What we do: Would score the ticket’s refund/change policy, fully refundable best, non-refundable worst.
Under the hood: Disabled (V12_DISABLED, 2026-08-04): fareFlexibility/awardFlexibility/eliteFlexibilityTier/fareComponents were only ever populated by a local test-fixture data path, so every real option was already neutral. Since 2026-09-01 the Duffel cash mapper derives fareFlexibility from the change and refund conditions on the offer, which Duffel sends on roughly a third of offers, so that half of the reason no longer holds for cash. The variable stays disabled: nothing has been re-validated yet, and award options still have no producer. Logic intact: computeV12's V12a cash / V12b award tier + operating-carrier elite bonus; min across fare components.
Limitation: Not scored today (always neutral 50). When re-enabled: real when the fare rules are present.
Tells you: Bag fees / allowance (currently disabled)
What we do: Would score whether your bags fly free, included best, high per-bag fees worst (carry-on + checked combined).
Under the hood: Disabled (V13_DISABLED, 2026-08-04): carryOnPolicy/freeBagAllowance/checkedBagFee/freeBagBonus were only ever populated by a local test-fixture data path, so every real option was already on the cabin-class proxy. Since 2026-09-01 the Duffel cash mapper populates carryOnPolicy and freeBagAllowance from the per-passenger baggages array, which Duffel sends on effectively every segment, so that reason no longer holds for cash. checkedBagFee and freeBagBonus still have no producer, and the variable stays disabled. Logic intact: V13a carry-on + V13b checked, weighted 40/60 (carry-on-only → V13a alone).
Limitation: Not scored today (always neutral 50). When re-enabled: real when the bag policy is present, otherwise a cabin-based guess.
Tells you: Lounge access on a connection (currently disabled)
What we do: Would give credit if your cabin/status/card gets you into a lounge during a connection.
Under the hood: Disabled 2026-09-02 (V14_DISABLED), reversing the 2026-08-31 switch-on. The lounge directory has not been integrity-checked and the scoring logic is undecided, so it must not move a ranking meanwhile. Logic intact: per-connection access averaged across connection airports, unknown airports excluded rather than assumed granted.
Limitation: Not scored today (always drops). The lounge data itself is unaffected and still served by the partner lounges endpoints.
Tells you: PreCheck / Global Entry / CLEAR (currently disabled)
What we do: Would give credit for the trusted-traveler programs you hold, at the US airports the trip touches.
Under the hood: Disabled (V15_DISABLED, 2026-08-04): deferred pending a reliable data pipeline for airport security-program configurations — preCheckByAirport/clearByAirport/globalEntryByAirport are only ever populated by a local test-fixture data path, so every real option was already assuming every airport supports every program. Logic intact: per-US-airport +50 PreCheck/GE, +30 CLEAR, +20 Global-Entry re-entry bonus.
Limitation: Not scored today (always drops). When re-enabled: based on your credentials; US airports only.
Tells you: Seat quality (pitch/width) (currently disabled)
What we do: Would pick a seat-quality tier for your cabin from pitch/width, nudge for premium features, and score the longest leg.
Under the hood: Disabled (V16_DISABLED, 2026-08-04) alongside V17/V18: most real seats already scored off the generic estimated-tier fallback anyway, so real seat-hardware coverage was thin in practice. Logic intact: inferV16Tier picks a cabin tier from pitch/width + a dimension modifier, on the longest segment.
Limitation: Not scored today (always drops). Real seat dimensions do reach it now: since 2026-09-01 the Duffel cash mapper carries seat pitch and lie-flat from the Duffel cabin amenities, present on every cash segment. Award options still have no seat data, so re-enabling would score cash from real pitch and drop award.
Tells you: Onboard catering
What we do: Would look up the catering level for the airline × cabin × route length and score it (multi-course best, none worst).
Under the hood: Re-enabled 2026-08-27 (V17_DISABLED = false). Its 2026-08-04 disable was never a data gap: the curated 168-airline dining-page table was real and actively differentiating scores when it was switched off. Re-enabling lifts Cabin Comfort & Hardware out of isCategoryFullyDisabled, so the category carries its weight again and every other category renormalizes. evaluateV17: explicit per-leg level → per-option cateringMatrix → curated reference (getCateringReferenceEntry); per-segment averaged; unavailable excluded.
Limitation: Scored today from a curated 168-airline table sourced from official airline dining pages. Segment-level tiers are averaged over segments with verified or estimated data; segments marked unavailable are excluded, and the variable drops when none resolve. With V16 retired it carries Cabin Comfort alone.
Tells you: Protects the worst-off traveler (currently disabled)
What we do: Would blend everyone’s scores but weight the worst-off traveler so one bad experience isn’t averaged away.
Under the hood: Disabled (V18_DISABLED, 2026-08-04) alongside V16/V17 — already inert before this disable, since no group-booking flow calls this aggregation yet. Logic intact: aggregateGroupScoresWithFloor: 60% group average + 40% worst individual.
Limitation: Aggregation rule for 2+ travelers; retired, and the floor blend itself is now bypassed (plain average) until re-enabled. It contributes nothing even though the category is weighted again.
Tells you: Is the cabin you asked for still bookable
What we do: Check whether the fare class you asked for still has seats: open means yes, sold out means no. Scored per leg and averaged.
Under the hood: scoreV19: reads details.seatCount (real — seats.aero per-cabin remaining seats). 100 if bookable, 0 if sold out, per leg, averaged. Activation is the cabin you actually stated, forwarded as searchContext.requestedCabin — no stated cabin means V19 drops, never an assumed economy. The richer seat-map ratio method stays gated off (V19_SEAT_MAP_METHOD_DISABLED) because this design scores fare class only; its logic is intact.
Limitation: Activation made real 2026-08-28: the search contract used to default cabinClass to economy, which made "you chose economy" indistinguishable from "you did not say". Coverage gap: only award options carry seatCount today, so V19 drops on live cash options.
Tells you: Can your group sit together (currently disabled)
What we do: Would check the seat map for whether your party can sit in the same row or close together.
Under the hood: Disabled (V20_DISABLED, 2026-08-04) alongside V21: already effectively inert — 0 of 111 real cash options had a seat map, and award never does. Logic intact: scoreV20 cluster/proximity detection over the cash-fare seat map.
Limitation: Not scored today (always drops), so it has no ranking impact even though the category carries weight again since 2026-08-24.
Tells you: Hotel near the airport (currently disabled)
What we do: Would score hotel availability/proximity at the destination airport.
Under the hood: Disabled (V21_DISABLED, 2026-08-04) alongside V20: unlike most disables this session, the airport-tier heuristic is real curated data — but it already never appeared live regardless (opt-in defaulted off). Logic intact: scoreALTHotel from an enrichment hotelProximityScore.
Limitation: Not scored today (always drops); category falls back to neutral 50.
Tells you: Seats still open, shown on the result
What we do: Show how many seats are still open (and how many windows/aisles) from the live seat map.
Under the hood: teofetch fetches GET /air/seat_maps per cash offer (15-min cache), maps it structurally, and summarizes to layout + open counts.
Limitation: Live seat availability — cash flights only; award/other providers have none.
Note: Seating Logistics carries real weight as of 2026-08-24 — 7 points, the same as the other tier-3 categories. This page said it carried 0 weight and never changed the ranking, which stopped being true that day. V19 (is the cabin you asked for still bookable) is its only live variable; V18 and V20 are retired. Since 2026-08-28 it activates on the cabin you actually stated in the search, not an assumed one. When V19 has nothing to score, the category is excluded from the total and its weight redistributes across the categories that did resolve — it is not scored zero, which would punish an option for data the provider never sent. Seat maps & the seatmap chip are cash flights only.
How a score is produced
The numeric score and the human-facing breakdown run on two independent paths — scoreCategory* produces the number that ranks options; buildAppliedVariables produces the display breakdown and never feeds the number.
averageApplicableScores() — a category is the mean of its active variables. A dropped variable is omitted, not scored 0. All inactive → neutral 50.
Some categories/variables override the mean: V4 internal 60/40 (V4b/V4c), Schedule & Time V5 0.50 / V4 0.30 / V6 0.20 when a time preference is set (else mean), Routing 40/30/30 (V7/V8/V9), Risk 30/35/35 (50/50 nonstop), V13 internal 40/60, Cabin Comfort & Hardware 0.65/0.35. Each renormalizes over active variables.
Positive wallet coverage adds +5 to Risk & Protection, then teoScore = clamp(base + adjustment).
Categories & weights
Weights come from getCategoryWeightsForPersona: positions 1–2 in a persona’s priority order get 25 pts, 3–5 get 12 pts, 6+ get 7 pts — then normalized to sum to 1. (The manifest defaultWeight is used only by a separate helper.) Bars show the default, no-persona allocation.
| Category | Pts | Default weight | Variables |
|---|---|---|---|
| Financial Value & Loyalty | 25 | V1V2V3 | |
| Risk & Protection | 25 | V10V11V12 | |
| Schedule & Time Efficiency | 12 | V4V5V6 | |
| Cabin Comfort & Hardware | 12 | V16V17 | |
| Routing & Logistics | 12 | V7V8V9 | |
| Ground Experience & Ancillaries | 7 | V13V14V15 | |
| Ground Connectivity | 7 | V21 | |
| Seating Logistics & Strategy | 7 | V18V19V20 |
* Seating Logistics carried weight 0 until 2026-08-24 — V19 ran but nothing it produced could reach a score. It now carries a real tier-3 weight, so V19 counts. V19 is its only live variable (V18 and V20 are retired), and when V19 drops for an itinerary — no stated cabin, or no fare-class availability — the whole category is excluded for that option and its weight redistributes, rather than scoring a zero the traveller did nothing to earn.
points_maximizer, executive, family_lead, luxe_enthusiast, digital_nomad, nervous_flier, weekend_warrior — which changes the point allocation above. With no persona, categories keep manifest order.Financial Value & Loyalty
scoreCategoryEconomicValue · price, points value, and mileage earning.
Measures how this itinerary’s cash price ranks against the other cash options in the same search.
- Activation
- Cash options only. Award itineraries are evaluated by V2.
- Drops
- On award itineraries; when no cash price is present.
- Notes
- Raw base price only — seat-selection fees and ancillaries are not folded in.
Score = 100 × (highestCash − thisCash) ÷ (highestCash − lowestCash) single result or all-equal prices → 100 no search context → clamp(100 − (cash / 3000) × 100)
Measures cents of cash value saved per point on an award redemption.
- Activation
- Award itineraries only.
- Cash fare
- Ladder: primary (exact flight) → same-carrier same-day → same-day median → ±1-day median. No comparator → V2 drops (the award’s own total price is not a comparator).
- Taxes
- Never estimated —
awardTaxesAndFeesortaxes+fees; if unavailable/malformed → V2 drops. - Flag
- Public
cashFareSource:primary/same-day-avg/±1-day-avg.
CPP = (equivalentCashFare − taxesAndFees) ÷ points × 100 // ¢/pt CPP ≥ 2.0 → 100 1.0 ≤ CPP < 2.0 → 50 × CPP 0 ≤ CPP < 1.0 → 20 × CPP CPP < 0 → 0 // negative-CPP floor: scores 0, stays active
Measures mileage/points earned relative to the best earner in the search. Disabled 2026-08-03 (V3_ELITE_EARNINGS_DISABLED) pending manual validation of earning-rate/PMV reference data, per V3’s own launch plan — logic below is intact for re-enabling, not scored today.
- Activation (when re-enabled)
- Cash bookings with
estimatedMilesEarned > 0and a comparator (bestEarningsValueCents). - Drops
- On award itineraries; no earnings data; no comparator. Currently always dropped.
Score = 100 × (estimatedMilesEarned × PMV) ÷ bestEarningsValueCents PMV per-program (getPMVForProgram) or default ≈ 1.3¢
Schedule & Time Efficiency
scoreCategoryTimeAndStructure · when a time preference is set, weighted V5 0.50 / V4 0.30 / V6 0.20 (else mean of active variables).
Measures journey length against the fastest option in the search, per dimension: V4b airport-to-airport elapsed (incl. layovers) and V4c pure air time.
- Weights
- V4b 60% + V4c 40%.
- V4a
- Door-to-door dropped — needs saved traveler addresses + ground-time (product dependency); weights collapse to 60/40.
per dimension d ∈ {V4b elapsed, V4c air time}:
Min = shortest d in search, Max = Min × 2.5
d ≤ Min → 100 ; d ≥ Max → 0 ; else 100 × (Max − d) ÷ (Max − Min)
V4 = (V4b × 0.6 + V4c × 0.4) // V4a inactive
no context → fixed brackets on elapsed (≤6h→95, ≤8h→85, …)Measures deviation from the traveler’s preferred time. Departure pref → first segment departure; arrival pref → last segment arrival; both → score each and average.
- Modes
- Window (100 within ±half-window, penalty beyond the nearest edge) or exact (penalty from minute one).
- Drops
- No preferred departure or arrival time (and no pre-computed score).
- Round-trip
- Outbound/return averaging is an orchestration concern (engine scores one itinerary).
Score = max(0, 100 − 7 × (deviationMinutes ÷ 30)) // symmetric, minute-level: 15m → 96.5 both departure + arrival set → (V5_dep + V5_arr) ÷ 2
Measures a red-eye penalty. Partial-launch scope: the timezone/jet-lag penalty is dropped for now — see Known Gaps below.
- Red-eye
- Computed in-engine (itinerary-level): dep 21:00–01:00 and arr 05:00–08:00, plus a UTC calendar-day constraint (≤24h / ≤12h). Falls back to the enrichment flag when offsets are absent.
- Waiver
- Red-eye penalty waived on explicit
redEyeIntentor a V5 window overlapping 21:00–01:00 by ≥50%.
Score = max(0, 100 − redEye) // red-eye = −20 unless waived // Jet lag (zones crossed × direction multiplier) is fully built and // tested but gated off (V6_JET_LAG_DISABLED) per V6's partial-launch spec.
Routing & Logistics
scoreCategoryRoutingStrategy · aggregation V7×0.40 + V8×0.30 + V9×0.30 when all active, renormalized over whichever are (nonstop → V7 alone). V9 is currently disabled, so a connection today is V7+V8 renormalized. Connection variables use min-wins.
Measures extra stops versus the most direct option in the search.
Score = max(0, 100 − 25 × max(0, thisStops − fewestStopsInSearch))
Measures connection-time comfort against the airport’s Minimum Connection Time (MCT).
- Modes
- minimize (default) full curve · extended (≥MCT → 100) · stopover (≥4h in chosen city → 100).
- MCT
- Enriched per-connection value when available, else 90/120-min domestic/international floor. Neither cash nor award data sources expose MCT — real per-airport MCT needs an OAG/SSIM feed.
- Drops
- Nonstop / no connection data.
L < MCT → max(0, 100 × (L/MCT) − 30) MCT ≤ L ≤ 2×MCT → 100 L > 2×MCT → max(0, 100 − (L − 2×MCT)/15) min-wins across connections
Measures difficulty of the physical transfer between flights, worst-connection wins. Disabled 2026-08-04 (V9_DISABLED): award (seats.aero) itineraries carry no terminal data at all, so every award connection would always fall to the carrier-coherence estimate with no path to a confirmed score. Disabled for cash itineraries too, not just award — logic and reference data below are intact for re-enabling, not scored today.
- Fallback (when re-enabled)
- No terminal data → carrier-coherence estimate on the tier scale (same carrier/alliance 70 · mixed 50), flagged
terminal-estimate. - Confidence
terminal-confirmedvsterminal-estimate;gate-confirmedis TEO Agent’s.- Drops
- Currently always dropped (disabled). Would also drop on nonstop (no connection).
cascading tiers per connection (spec numbering): Tier 4 same terminal → 100 Tier 3 airside-connected → 70 Tier 2 exit + reclear → 50 Tier 1 different airports → 15 min-wins across connections
Risk & Protection
scoreCategoryRiskAndReliability · fixed weighting — V10 active (connecting, not disabled) V10×.30 + V11×.35 + V12×.35; V10 inactive (nonstop, or disabled) V11×.50 + V12×.50. +5 loyalty boost when wallet coverage is positive.
Measures who’s responsible if a connection is missed — worst-connection wins. Disabled 2026-08-04 (V10_DISABLED): an explicit product decision, no specific data/scope gap recorded — logic below is intact for re-enabling, not scored today.
- Inference (when re-enabled)
- No explicit tier → per connection: same marketing carrier 100 · same alliance/interline 80 · unrelated/unknown 50. Weakest link sets V10; any self-transfer → 0.
- Drops
- Currently always dropped (disabled). Would also drop on nonstop (no connection to protect).
explicit tier wins: single_ticket 100 · alliance_interline 80 · insured 50 · self_transfer 0 else per-connection min-wins (marketing carrier → alliance → partial)
Measures how realistically the traveler still reaches their destination today if the flight falls through — more same-day alternatives, especially on the same airline and arriving soon after the original, means easier rebooking.
- Weights
- Per qualifying alternative: Time_Weight (10/7/5/3 for <2h / 2–6h / 6–12h / >12h later than the original's scheduled arrival) × Carrier_Weight (1.0 same marketing carrier, 0.5 interline). Summed into a floor from the direct/connecting × N≥3-or-N<3 Base Floor Matrix (75/25/40/10).
- Data (fixed 2026-08-04)
- Alternatives are the other options already in this same search sharing the exact origin+destination — no new provider data needed, same pattern as V1/V4/V7's search-relative benchmarks. A richer
details.recoveryFlightsfeed still takes priority when present, but nothing populates it live today. - Gotchas
- Metro/cross-airport alternatives don't count (launch scope excludes them). A late-arriving original itinerary can only ever reach the <2h/2–6h bands, since later bands would cross into the next local calendar day and stop qualifying.
Contribution = Time_Weight × Carrier_Weight Score = min(100, Base_Floor + ΣContribution) // always active
Measures change/cancel freedom — per fare component, weakest wins. Disabled 2026-08-04 (V12_DISABLED): not enough data validated — fareFlexibility/awardFlexibility/eliteFlexibilityTier/fareComponents are only ever populated by a local test-fixture data path, never by the real Duffel/seats.aero normalizers, so every real option was already landing on the neutral/lowest-tier fallback — logic below is intact for re-enabling, not scored today.
- Elite bonus (when re-enabled)
- Operating-carrier-matched tier (
details.eliteFlexibilityTier): cash +0/+5/+10/+15, award +0/+0/+20/+30, clamped to 100. - Mixed fare (when re-enabled)
- Score each component from
details.fareComponents→ minimum. - Drops
- Currently always neutral 50 (disabled).
per component: min(100, Base + Elite_Bonus); V12 = min across components V12a cash: full_refund 100 · free_change 75 · fee_change 50 · none 0 V12b award: free_cancel 100 · redeposit_low 70 · redeposit_high 45 · no_cancel 0
Ground Experience & Ancillaries
scoreCategoryGroundExperience · aggregation V13×0.50 + V14×0.25 + V15×0.25. V13 (Baggage) is the universal anchor and never drops; when V14 or V15 drops the pair reweights to V13×0.65 + other×0.35, and V13 alone if both drop. V14 and V15 are both currently disabled and always drop, so only the pinned V13 is left and the category carries zero applied weight today, which keeps that pinned 50 out of the total.
Measures carry-on and checked-bag generosity, combined. Disabled 2026-08-04 (V13_DISABLED): not enough data validated — carryOnPolicy/freeBagAllowance/checkedBagFee/freeBagBonus are only ever populated by a local test-fixture data path, never by the real Duffel/seats.aero normalizers, so every real option was already landing on the cabin-class-proxy fallback — logic below is intact for re-enabling, not scored today.
- Drops
- Currently always neutral 50 (disabled).
V13a carry-on: included 100 · personal-only max(0, baseline−min(baseline,fee/5)) [baseline 60 if carry-on-only else 20] · fee clamp(100−fee×1.5)
V13b checked: free = freeBagAllowance + details.freeBagBonus (elite/card); needed = defaultCheckedBagCount ?? 1
free ≥ needed → 100 · else max(0, 100 − (needed−free)×fee×2) · shortfall+unknown fee 40
Combined = carry-on-only ? V13a : V13a × 0.40 + V13b × 0.60 // cabin proxy when no real dataMeasures whether the traveler has lounge access at each connection. Switched on 2026-08-31 and disabled again 2026-09-02 (V14_DISABLED): the lounge directory has not been integrity-checked and the scoring logic is undecided, so it must not move a ranking meanwhile. Not scored today. The lounge data itself is unaffected and still served by the partner lounges endpoints. Logic below is intact for a re-enable.
- Activation
- None today, the variable always drops. When enabled: a connection present and a qualifying access path (business/first cabin, or elite/lounge-card flag). Persona does not gate.
- Drops
- Currently always drops (disabled). When enabled it also drops on a nonstop, or with no qualifying path at all.
per connection airport: lounge accepts a path → 100 · else → 0 (qualified-but-no-access = 0, not drop) V14 = average across connection airports // connections only; origin/dest excluded
Measures expedited-security benefit from the traveler’s credentials (US programs). Disabled 2026-08-04 (V15_DISABLED): deferred for initial launch pending a reliable data pipeline for airport security-program configurations — preCheckByAirport/clearByAirport/globalEntryByAirport are only ever populated by a local test-fixture data path, so every real option was already falling back to an "assume supported" default — logic below is intact for re-enabling, not scored today.
- Activation (when re-enabled)
- Holds PreCheck/Global Entry/CLEAR and itinerary touches a US airport. Persona does not gate.
- Drops
- Currently always drops (disabled). Would also drop with no credential / no US airport anywhere.
per US security airport: (PreCheck or GE) supported +50 · CLEAR supported +30 Per_Airport_Average = mean across relevant airports (first departure + self-transfer starts) Global_Entry_Bonus = +20 if GE works at the US re-entry airport (first US arrival from abroad) V15 = min(100, Per_Airport_Average + Global_Entry_Bonus)
Cabin Comfort & Hardware
scoreCategoryCabinAndSeat · aggregation V16 × 0.65 + V17 × 0.35, or whichever of the two resolves alone. V16 is currently disabled and drops, so this category is the V17 catering score today; when V17 has no reference row either, the category drops and its weight redistributes.
Measures seat quality (pitch, width, recline, type) from seat-map data, scored within the searched cabin. Disabled 2026-08-04 (V16_DISABLED) alongside V17/V18: most real seats already scored off the generic estimated-tier fallback rather than a confirmed tier, so real seat-hardware coverage was thin in practice. Pinned to a flat 50 rather than dropped, since V16 structurally never drops. Logic below is intact for re-enabling, not scored today.
- Tiers (when re-enabled)
- Econ 100/70/50/35/20 · Prem-econ 100/75/55 · Biz 100/85/70/50/30 · First 100/80/60.
V16 = clamp(tier + dimensionModifier + featureBonus, 0, 100) // longest segment, cabin-scoped dimensionModifier = clamp(pitchMod + widthMod, −15, +15) // ±5/±10 per dimension vs tier range featureBonus (business/first only): direct-aisle +5 · suite-door +5 · bed-length-over-norm +3–5
Measures the catering service tier per segment (what’s offered, not food quality), scored per cabin and averaged over segments with reliable data. Re-enabled 2026-08-27 (V17_DISABLED = false). The 2026-08-04 disable was not a data gap: the curated 168-airline catering table below is real and was actively differentiating scores in production, which is why it was switched back on. Scored today; with V16 retired this variable carries the category alone.
- Tiers (when re-enabled)
- multi-course 100 · hot meal 90 · cold meal 70 · buy-on-board 50 · snack 45 · beverage-only 25 · none 0.
- Activation (when re-enabled)
- Per segment via
carrier × cabin × duration; unavailable segments excluded (not scored 0). Confidence: verified / estimated / unavailable. - Drops
- Drops when no segment resolves a catering row, for example a carrier absent from the reference table. With V16 retired there is nothing left to fall back to, so the category drops with it and its weight redistributes.
Seating Logistics & Strategy
scoreCategory7SeatingLogistics · folded into the weighted base score since 2026-08-24. V19 is the only live variable, and activates on the cabin you stated in the search; the category is excluded per-option when V19 drops.
Measures nothing new about the itinerary — an aggregation rule that blends the per-traveler scores so one uncomfortable traveler doesn’t get averaged away. Disabled 2026-08-04 (V18_DISABLED) alongside V16/V17 — already inert before this disable, since no group-booking flow calls this aggregation yet. Group_Final_Score now always equals Group_Average; the floor blend below is intact for re-enabling.
- Activation (when re-enabled)
- 2+ travelers. Solo collapses to the traveler’s own score (unaffected by the disable).
- Confidence
- Lowest wins —
default-personaif any companion lacks a profile, elseprofile-based.
Group_Final = groupAverage × 0.60 + groupFloor × 0.40 // floor = min individual; currently bypassed (Group_Final = groupAverage)
Measures whether the booked fare class/cabin is still bookable at all — open seats or sold out. Specific seat types are not considered.
- Method
details.seatCount(real — seats.aero per-cabin remaining seats for award, Duffel per-cabin seats for cash). Open → 100, sold out → 0.- Drops
- Without a stated cabin, or when no remaining-seat count is reported. A reported
0counts as not reported: AAdvantage never publishes a count and arrives as 0, while every other award program does, so treating 0 as sold out penalised every AA award on every search. - Seat-map ratio
- Gated off 2026-08-24 (
V19_SEAT_MAP_METHOD_DISABLED) to scope V19 to its launch design. It scored a continuous ratio of preferred-open to total-open seats and outranked the 100/0 formula above. Logic intact for re-enabling; the profile→scoring pipeline that feeds it is still wired. - Activation
- The cabin you stated in the search, forwarded to scoring as
searchContext.requestedCabin. Real since 2026-08-28 — the request schema used to defaultcabinClassto economy, so no code could tell a chosen economy from an unstated one. No stated cabin now drops V19 instead of scoring an assumption. - Coverage gap
- Every producer of
details.seatCountis a seats.aero path, so award options score and live cash options drop for want of the field. Because a dropped category renormalizes, that hands award options a small edge over otherwise identical cash ones. Closing it needs Duffel per-cabin availability, not a scoring change.
seatCount > 0 → 100 (fare-confirmed) no cabin stated → drops · seatCount absent or 0 → drops
Measures whether a group of 2+ can sit together — cluster (adjacency) or, in single-seat cabins, proximity (row distance). Disabled 2026-08-04 (V20_DISABLED) alongside V21: already effectively inert before this disable — a live check found 0 of 111 real cash options had a seat map, and award itineraries never do (seats.aero has no seat-level API). Logic below is intact for re-enabling, not scored today.
- Activation (when re-enabled)
- 2+ travelers and a seat map for at least one segment.
- Drops
- Currently always drops (disabled). Would also drop for a solo group or with no seat map for any segment (no guessing).
cluster: single-row ≥ N → 100 · near-cluster ≥ N → 90 · else 100 × max(single, near) ÷ N proximity: min row-spread over N seats → same-row 75 · 1 apart 60 · 2 apart 40 · 3 apart 25 · 4+ apart 10
Ground Connectivity
scoreCategoryGroundConnectivity · V21 alone; neutral 50 when V21 drops — which is unconditional now that V21 is disabled.
Measures hotel availability and proximity at the destination airport (hotelProximityScore from enrichment). Disabled 2026-08-04 (V21_DISABLED) alongside V20: unlike most disables this session, the airport-tier heuristic is real curated data — but it already never appeared live regardless (opt-in defaulted off, confirmed live before disabling). Logic below is intact for re-enabling, not scored today.
- Drops
- Currently always drops (disabled). Would also drop when opt-in is off (
altHotelOptIn === false) or score absent. - Roadmap
- Phase 1 heuristic → Phase 2 live hotel API.
Removed in TEO21 Draft 1. scoreALT() is retained for reference but does not contribute to any category score.
Search-relative context
Built once per search by buildSearchContext(options) and threaded into per-option scoring. Without it, V1 falls back to a fixed-range curve; V4 has no benchmark to compare against and does not activate.
| SearchContext field | Feeds |
|---|---|
| lowestCashPrice / highestCashPrice | V1 |
| lowestAwardPrice / highestAwardPrice | award price diagnostics |
| shortestElapsedMinutes | V4b (airport-to-airport) |
| shortestAirTimeMinutes | V4c (air time) |
| fewestStops | V7 |
| preferredDepartureTime / preferredArrivalTime | V5 |
| redEyeIntent | V6 waiver |
| layoverMode / stopoverCity | V8 |
| bestEarningsValueCents | V3 |
Confidence & score tiers
best-in-search / low-risk
middle of the field
worst-in-search / high-risk
V2 confidence flags
| cashFareSource (public) | Internal tier | Meaning |
|---|---|---|
| primary | exact | same flight, cabin, date |
| same-day-avg | near_equivalent, market_proxy | same-day comparable(s) |
| ±1-day-avg | temporal_proxy | ±1 day median |
| null | unavailable / provided CPP | no source to report |
There are no award valuation modes — strict/permissive were removed on 2026-07-22 along with tax estimation.