This release is a pre-release and may not be stable for production use.
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.tomlfetches 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.
nlegs haven − 1stops, 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_nightsgoes 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_byare 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
scaleknob 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:
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).docs/ORACLES.md— oracle contract, browser harness and anti-detection, per-site notes, health monitoring, etiquette and legal posture.docs/ROADMAP.md— milestones M0–M6 with acceptance criteria, testing strategy, repo layout, open questions.
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.
$ 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)
# 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
- 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.
- 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.
- 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.
- 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. - Risk is part of the result. Self-transfers, split tickets and hidden-city itineraries are annotated, and never silently mixed with protected itineraries.
- 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:
$ 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file layover-0.1.0.dev0.tar.gz.
File metadata
- Download URL: layover-0.1.0.dev0.tar.gz
- Upload date:
- Size: 641.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13e7956f5cfd2e69bdaff58116417f39fb507121fa4cf88153ada618e0b7e3c7
|
|
| MD5 |
ecee157a3c54c2acc866f314ba947910
|
|
| BLAKE2b-256 |
8626698e18506dcb50a758bbb6cdff559a949db231cf8a447eb481aa86c1e539
|
File details
Details for the file layover-0.1.0.dev0-py3-none-any.whl.
File metadata
- Download URL: layover-0.1.0.dev0-py3-none-any.whl
- Upload date:
- Size: 144.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
063a6d16b5f9772351fddf31f09434fcf75fcf9a7ff989914056320c4d3ba31d
|
|
| MD5 |
35722bb4becf50e276d7c5bba901290c
|
|
| BLAKE2b-256 |
61b5ab1afead364bd36eb9c2ad09b3ca08db43b77eb2c5f46ef1b7ddd4b09834
|