Metadata-Version: 2.5
Name: layover
Version: 0.1.0.dev0
Summary: Personal flight-search strategist: a budgeted orchestrator over unreliable flight-search oracles
License: BSD-3-Clause
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: browser
Requires-Dist: playwright>=1.44; extra == 'browser'
Description-Content-Type: text/markdown

# layover

**A personal flight-search strategist.** Not another search engine — an *orchestrator*
that treats existing flight sites as expensive, unreliable oracles and spends a bounded
budget of queries deciding *which* searches are worth making.

> **Status: M0 done — it runs.** `layover search trips/example.toml` fetches live
> Google Flights data, normalizes it to one currency, prices every result under your
> own valuations and prints a ranked table with the reasoning attached. 199 tests, all
> offline (a recorded fixture keeps the parser honest). No solver yet: one query per
> run, no split probes, no date sweep — that is M1. And note that Google answers an EU
> address with a consent wall, so results today come through a non-EU exit, which
> means non-home-market prices; every offer records which exit produced it.

## The problem

Every flight site covers a slice of the space — virtual interlining on Kiwi, raw coverage
and calendar pricing on Google Flights, hidden-city on Skiplagged, European LCC one-way
combinatorics on AZair — and none of them supports the search strategy that actually finds
cheap long trips:

- decompose an itinerary into legs at plausible hubs, and price the legs separately;
- consider deliberate stopovers instead of connections;
- compare a return fare against two one-ways, possibly on different carriers, possibly
  bought in different markets;
- do all of that under a real baggage profile, because a €20 fare with a €45 bag is not
  a €20 fare;
- and check whether the same query is cheaper from a different point of sale.

That is a workflow of a dozen browser tabs and a scratchpad. `layover` automates the
workflow, not the search.

## How it works

Three facts drive the whole design: **prices are non-additive** (a through fare may be
cheaper *or* dearer than the sum of its legs, and a return may be cheaper *or* dearer than
two one-ways), **observation is expensive** (every price costs latency and block-risk), and
**prices are context-dependent** (point of sale changes them). So the system probes rather
than derives, and the interesting engineering is budget-constrained exploration.

A search runs in four phases:

| Phase | What happens |
| --- | --- |
| 0 — baseline | Broad through-searches on the cheapest oracles across the date windows. Establishes `P*`, the **generalized-cost**-to-beat (not the fare to beat). |
| 1 — structure | Harvest hubs, carriers and fare patterns out of the baseline results; merge with a curated hub list to get candidate split and stopover points. |
| 2 — probes | Spend the remaining budget on split probes, return-vs-two-one-ways, baggage variants, point-of-sale probes, optional hidden-city scan — interleaved, re-ranked after every result, stopped early when a family can no longer win. |
| 3 — verify | Re-fetch the shortlist and mark each plan `verified / changed / gone`. |

## What it optimizes

Not price — **money-equivalent personal utility**, because the cheapest flight is
routinely the wrong one and there was previously no way to say so. Every plan gets one
comparable scalar, `generalized_cost`, with the sticker price always displayed beside it:

- **Time has a price** (default €25/h, configurable), and not one price: in-flight time
  is discounted, dead layover time is not, short connections are worse than long ones,
  red-eyes cost extra, and a lounge turns a 4-hour wait from €100 of dead time into €35.
  An overnight wait is priced as the cheaper of a terminal night or an airport hotel,
  and the report says which it assumed.
- **Delay is systematic, so it is priced.** Morning departures leave on time; slip
  accumulates through the afternoon and is worst in the evening. That prior sets both an
  expected cost per segment and the misconnect probability behind every self-transfer —
  and missing the *last* flight of the day costs a hotel and a day, not a rebooking.
  Then the **shortlisted flights get checked against their recent punctuality record**
  and the estimate is blended with the prior, so a habitually-late leg can lose a plan
  its place — small samples nudge, large samples decide.
- **The search decides what to ask, and says what it spent.** A baseline pass
  establishes the cost to beat; then probes spend what the budget allows on whether a
  different *shape* of purchase does better — the first being return versus two
  one-ways, settled by arithmetic rather than folklore. Probes that cannot learn
  anything are skipped: AZair's answers are already composed one-ways, so asking it
  that question would spend two queries rediscovering what it just said.
- **A trip is stops and the legs between them.** There is no "outbound" and no
  "return" in the model: an out-and-back is three stops with the holiday in the middle,
  an open jaw or a four-city trip is the same list with different entries, and each
  stop states when to land, how long to stay and when to leave. Round-trip survives one
  layer down, in the *offer* — it is a product airlines sell, and often cheaper than two
  one-ways, which is a probe family rather than a fact about the journey.
- **Every gap is a stop — a 40-minute transfer and a fortnight are one object.** `n`
  legs have `n − 1` stops, each with its own parameters, and the destination stay is
  simply the one in the middle. What separates a miserable layover from a stopover is
  not length but whether it was *wanted*, which is a valuation, not a class. Three
  numbers describe any of them: the length you want, what that is worth **net** (hotel
  minus enjoyment — one signed number, because a hotel rate is guessable and what Punta
  Cana is worth to you is not), and how fast it sours. A transfer is the same function
  with the value at zero. Wanted stopover time also leaves the time account, and makes
  an airport change *cheap* — you were leaving the airport anyway.
- **A stop worth having is the only negative cost, and it bounds itself.** A fortnight
  in Punta Cana at −800 is worth +∞ for a decade, because the square eventually swamps
  any offset — so nothing has to cap the holiday, and `max_nights` goes back to meaning
  "I cannot stay past the 30th". The marginal night is the curve's slope: the first
  night past your ideal is nearly free, the tenth is not.
- **And stops are usually *discovered*, not planned.** You don't want Santorini until a
  Dubai fare turns out to connect there, or Punta Cana until it appears on the way to
  Bogotá. So a long gap is neither buried as dead time nor assumed to be a holiday: it
  becomes a **priced question** — *"this routing sits 50 h in JTR; taking it as two
  nights beats waiting unless the stay costs you more than €200 net"* — with the default
  answer always no, and no question at all for places already on your wishlist.
- **Flexibility is a price, not a range.** A window is `min` / `ideal` / `max`, and the
  cost between them is quadratic in the distance from the ideal — flat at the bottom,
  steep at the edges, which is how the preference actually behaves. So "±3 days" stops
  meaning "all equally fine", and where the ideal sits inside the window *is* the
  statement about which side is tighter. Any two of the three are enough.
- **Departure, arrival and length are the same window on three axes.** When you leave,
  when you land, how long you stay — any two fix the third, so you state two and the
  system prices each on its own. Arrival written as clock times (`ideal = 16:00`)
  recurs daily, since "land before midnight" is true of whichever day you land; the
  clock wraps, so "ideally 16:00, never after 01:00" is nine hours of tolerance.
  Lengths use the same curve counted in nights, for the trip and for each stopover —
  an allowance of one-to-three nights in Istanbul is not indifference between one and
  three.
- **Borders cost time, per traveller.** Some airports transfer you airside; the US makes
  you enter and exit, which is 105 minutes, a bag re-check and an ESTA. Passports are an
  asset the solver spends, choosing the cheapest admissible one per border **for each
  person travelling** — so a routing that is trivial for a HU/GB passport can need a UK
  ETA and a US visa-waiver check for an IT/BR one, and that shows up before booking
  rather than at the gate. Time takes the party's worst case; fees are per person.
- **Lounge access is a scarce budget.** Each traveller's DragonPass quota (10 free
  entries a year, tracked per person) is shadow-priced — near zero while quota is
  plentiful, rising to the paid rate as it runs out. Access counts only where the lounge
  is in the terminal the plan actually uses, and a party of two spends two entries.
- **One-off costs are amortized, and both figures shown.** An ESTA is an hour of your
  time and a fee, then it covers two years of trips — so ranking uses the amortized
  share while the report still shows what leaves the account this month. Inside the
  official lead time the routing is infeasible, not merely expensive.
- **Risk is a probability times a consequence**, and nothing else: no premiums, no
  arbitrary "beat it by 8 %" floor. The hassle of a self-transfer is certain, so it is
  discomfort; the chance of missing it is uncertain, so it is risk. Neither stands in
  for the other.
- **There is no "trip type" — there are hard constraints with stakes.** A conference
  has a fixed opening session; the way there may be wide open, which is how three days
  in Punta Cana end up in the itinerary. So `arrive_by` / `depart_after` / `home_by`
  are *checked* with a buffer, never priced — and each carries a **criticality**,
  because "definitely arrive by 20:00" cannot be enforced against a timetable alone: a
  plan that intends to land at 14:00 with a 15 % chance of arriving tomorrow satisfies
  the schedule and fails the requirement. Stakes then drive everything a "business
  mode" used to fake: expected violation cost, how much cushion a connection earns,
  and whether a fragile split is worth it. A self-transfer three days before the
  conference is free; the same one the night before is not. Constraints also define
  the slack a discovered stopover may spend.
- **Comfort is its own price, not a multiple of your hourly rate.** Time (hours you
  don't get back) and discomfort (a broken night, running for a gate, dragging bags
  across a city) are separate families, with a single `scale` knob for "how much do I
  mind things" — because minding airports shouldn't rise with your billing rate.
- Because some of those numbers are guesses, every report prints the **breakeven rate**
  at which the top two plans swap places, and flags rankings that hinge on an assumption.

Judgment calls that belong to *this trip* — is the ESTA still valid, is a 4-hour
self-transfer at IST fine today, is €35 for a lounge worth it — are **asked, not
assumed**, and every question carries the pros and cons of each option. Interactive when
there's a terminal, defaults (all recorded) when there isn't, and over MCP the questions
are handed to Claude to ask in plain language.

Everything the solver sees is normalized first: `Segment` → `Leg` → `Offer` → `TripPlan`,
one currency, baggage costs attached, risks annotated. Sites sit behind an oracle contract
with a **capability descriptor**, so the solver plans against capabilities (`date_range_query`,
`virtual_interline`, `baggage_filter`, `pos_controls`, cost vector) and never against site
names. Each oracle has a health state machine — `healthy → throttled → cooling → blocked` —
with token buckets, canary recovery probes, and a visible status surface.

Read the docs in this order:

1. [`docs/DESIGN.md`](docs/DESIGN.md) — architecture, intent model, data model, solver,
   baggage-aware totals, **the generalized-cost objective (§8)**, the interaction layer
   (§10), point-of-sale handling, recorded trade-offs, and the settled-decisions log (§13).
2. [`docs/ORACLES.md`](docs/ORACLES.md) — oracle contract, browser harness and
   anti-detection, per-site notes, health monitoring, etiquette and legal posture.
3. [`docs/ROADMAP.md`](docs/ROADMAP.md) — milestones M0–M6 with acceptance criteria,
   testing strategy, repo layout, open questions.

[`CLAUDE.md`](CLAUDE.md) is the short version, for Claude Code and for humans in a hurry.

## Planned shape

None of this exists yet; it is here so the target is unambiguous.

```console
$ layover search trips/bkk-november.toml    # budgeted search, ranked by generalized cost
$ layover search … --non-interactive        # never ask; record every assumption used
$ layover search … --remember               # promote an answered question into config
$ layover status                            # per-oracle health, today's query spend vs cap
$ layover knowledge                         # coverage gaps and cooldowns remembered across runs
$ layover verify <session> <plan-id>        # re-price one plan
$ layover oracle reset kiwi                 # after inspecting a blocked adapter
$ layover gc                                # drop expired session stores (--all: reports too)
```

```toml
# trips/bkk-november.toml
origins      = ["BUD", "VIE"]
destinations = ["BKK"]
trip_shape   = "return"
comfort_scale = 1.0                   # how much I mind things, independent of wage
party        = ["owner", "spouse"]    # passports & authorizations per traveller
normalize_currency = "EUR"

# earliest possible, what I actually want, latest possible — and what the edge is
# worth avoiding. Cost = cost_at_tolerance * ((when - ideal) / tolerance)^2, with the
# tolerance defaulting to the distance from the ideal to each bound.
[date_windows.outbound]
min = 2026-11-05
ideal = 2026-11-07
max = 2026-11-09
cost_at_tolerance = 45

# times are allowed wherever dates are, and then it is priced in hours
[date_windows.inbound]
min = 2026-11-25T06:00:00
ideal = 2026-11-28T10:00:00
max = 2026-12-01T23:00:00
blocked = [2026-11-26]                # unavailable, not merely expensive

[date_windows.outbound.arrive]         # when I want to be there; clock times, so
ideal = 16:00:00                       # it means whichever day I land
max = 01:00:00

[duration]                             # the same window, counted in nights
min_nights = 16, ideal_nights = 21, max_nights = 24

# hard: checked, never priced — and it defines the slack a stopover may spend
[[constraints]]
kind = "arrive_by"
place = "BKK"
when = 2026-11-09T09:00:00
buffer_hours = 12
label = "workshop opening"

criticality = "unmissable"      # what missing it would cost: drives cushion and risk

# ...or write the journey out: n stops, n-1 legs, no direction anywhere
# [[stops]]
# place = "BUD"
# depart = { min = 2026-11-05, ideal = 2026-11-07, max = 2026-11-09 }
# [[stops]]
# place = "FCO"
# ideal_nights = 3, net_at_ideal = -120     # three days in Rome, net of the hotel
# arrive = { ideal = 14:00:00, max = 22:00:00 }

[stopovers]
allow = true
specs = [
  { where = ["IST", "DXB", "SIN"], min_nights = 1, ideal_nights = 2, net_at_ideal = -90 },
]

baggage_profile = "cabin_bag"
risk_tolerance  = "self_transfer_ok"
pos_probing     = "shortlist_only"

[preferences.time]                    # per-trip override of ~/.config/layover
wage_rate_per_hour = 25.0

[budget]
max_queries_total    = 120
max_wallclock_minutes = 25
```

A second interface is the point of the exercise: an **MCP server** exposing
`search / probe / status / explain / verify`, so Claude can be the natural-language front
end and iterate conversationally ("hold Istanbul two nights instead"). The CLI comes first
because it is easier to debug.

Planned layout (see `docs/ROADMAP.md` for the full tree):

```
src/layover/{models,intent,session,currency}.py
src/layover/solver/    phases, probe scoring, composition, risk
src/layover/oracles/   base, scheduler, health, api/*, browser/*
src/layover/pos/       POS identities, proxy exits
src/layover/report/    terminal + HTML
flows/*.toml           per-site navigation steps and selector maps
config/, reference/    config; airports, hubs, carrier ancillary fees
tests/                 recorded fixtures + golden parses, MockOracle solver tests
```

## Principles that are not up for negotiation

1. **No offer hoarding.** Prices live in a session-scoped SQLite store and are discarded
   when the session ages out. Only small, slow reference data (airports, hubs, ancillary
   fee tables) is versioned in the repo.
2. **Query budget = politeness budget.** Hard per-oracle rate limits enforced by the
   scheduler regardless of what the solver wants; duplicate queries are never re-sent.
3. **Breakage is a normal event.** Adapters will break. Detect it fast, degrade to other
   oracles, show the state, and keep repair cheap — selectors in TOML, parsers tested
   against recorded fixtures.
4. **Total cost, not sticker price.** Always under the user's baggage profile, always in
   one currency, with unknown fees shown as `+ €?..€?` bands and never as zero — and,
   for ranking, always as generalized cost including time, borders and risk.
5. **Risk is part of the result.** Self-transfers, split tickets and hidden-city
   itineraries are annotated, and never silently mixed with protected itineraries.
6. **Assumptions are visible.** Anything the system had to assume rather than know is
   named in the report with its source, and anything that can strand you is asked.

## Etiquette, ToS and legal posture

Stated plainly so that nobody later "optimizes" it away:

- **Personal-use volumes only.** The token buckets (browser oracles ~4–6 queries/minute
  burst, ~60–100/day) encode that, and the per-search query budget is a politeness budget.
- **One in-flight query per oracle**, 2–3 concurrent browser oracles globally. This is an
  etiquette rule, not a performance bug.
- **No CAPTCHA auto-solving**, no login or paywall circumvention. On a CAPTCHA the oracle
  goes `cooling`, a screenshot lands in the session directory, and traffic routes elsewhere.
- **No republication** of scraped offer data; session-scoped caching only. Deep links and
  referral mechanisms are respected — they are what the sites want anyway.
- Scraping consumer sites against their ToS is a grey zone even for personal use. The
  accepted worst case here is **being blocked**, and the system is built to notice and
  degrade gracefully when that happens rather than to evade harder.

## Data and privacy

Session stores live under `~/.local/state/layover/sessions/`, age out after 14 days, and are
removed by `layover gc`. Exactly three things persist across sessions: per-oracle health and
etiquette state (otherwise backoff is meaningless), browser profile directories, and —
**opt-in and off by default** — route-level price quantiles (`origin_metro, dest_metro,
month`) with no offer detail, used only to score probe candidates. HTML reports are treated
as user-owned documents: they contain prices, they persist, and `gc` leaves them alone unless
you pass `--all`. Personal data lives in one separate file, `~/.config/layover/travelers.toml` (`0600`,
gitignored, never in the repo): passports as nationality plus expiry, authorizations,
DragonPass quotas — and deliberately **no document numbers**, since nothing here books
anything and the cost model never needs them. Valuations (time rates, premiums, lounge
pricing) live apart from identity, in `preferences.toml`. One further cache is
deliberately long-lived: per-flight punctuality history in
`~/.local/state/layover/punctuality.sqlite`, kept 30 days — it is a fact about a flight, not
a price, and re-fetching it every session would be pure waste.

Scope note: v1 covers **1–2 adults on one shared itinerary** (child fares and per-passenger
bag maths differ too much between sources to fake), and the repo is private — which is what
makes committing sanitized site fixtures acceptable.

## Development

Python 3.12+, `pydantic` v2, `httpx`, Playwright (anti-detect variant behind a config
switch), SQLite, TOML config. Tooling is `uv` and `ruff`:

```console
$ uv sync                 # create the environment
$ uv run pytest           # offline suite: cost model, feasibility, borders, lounges
$ uv run ruff check .     # lint
$ uv run ruff format .    # format
```

The suite runs offline against hand-built itineraries and recorded fixtures; live
queries are opt-in via `pytest -m live` and never run in CI. Preference models forbid
unknown keys, so a mistyped config key is a startup error rather than a silently
dropped preference — which is to say, rather than a wrong ranking that looks right.

## Name and license

*layover* is a working name — the gap between legs, which this project treats as a
first-class priced object rather than dead time. Rename freely.
Licensed under the BSD 3-Clause License; see [LICENSE](LICENSE).
