layover 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ """The phased search: establish a baseline, then spend what is left on probes (§6).
2
+
3
+ This is the part the rest of the project exists to serve. Everything else — the cost
4
+ model, the windows, the knowledge store, the credit-aware budget — is there so that
5
+ *this* can decide which questions are worth asking, and stop when they are not.
6
+
7
+ Two phases so far:
8
+
9
+ **Phase 0 — baseline.** Ask the cheapest oracle that can answer, price everything it
10
+ returns, and take the best generalized cost as `P*`, the number to beat. A search that
11
+ did only this would be an ordinary flight search with a better ranking function.
12
+
13
+ **Phase 1 — probes.** Ask whether a different *shape* of purchase does better. The
14
+ first family is return versus two one-ways, which costs two queries and is decided by
15
+ arithmetic rather than by folklore about which is cheaper.
16
+
17
+ Every phase reports what it spent and what it bought, because a probe that found
18
+ nothing is a real result: it means the baseline shape was right, and next time the
19
+ budget can go elsewhere.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+
27
+ from layover.cost.generalized import PlanCost, PricingContext, price_plan
28
+ from layover.models import TripPlan, TripShape
29
+ from layover.oracles.base import OracleQuery
30
+ from layover.solver import probes
31
+ from layover.solver.budget import QueryBudget, Quote
32
+
33
+ Ask = Callable[[OracleQuery], list[TripPlan]]
34
+ """Send one query to whichever oracle was chosen and return what it offered."""
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class Finding:
39
+ """What one phase produced, in the terms the report needs."""
40
+
41
+ phase: str
42
+ plans: list[TripPlan] = field(default_factory=list)
43
+ queries: int = 0
44
+ note: str = ""
45
+
46
+ @property
47
+ def found(self) -> int:
48
+ return len(self.plans)
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class SearchResult:
53
+ ranked: list[tuple[PlanCost, TripPlan]] = field(default_factory=list)
54
+ findings: list[Finding] = field(default_factory=list)
55
+
56
+ @property
57
+ def best(self) -> tuple[PlanCost, TripPlan] | None:
58
+ return self.ranked[0] if self.ranked else None
59
+
60
+ def improvement(self) -> str:
61
+ """Did any probe actually beat the baseline, and by how much?
62
+
63
+ Stated as money because that is the only form in which "was it worth two more
64
+ queries" is answerable. A probe that changed nothing says so.
65
+ """
66
+ if not self.ranked:
67
+ return "nothing to compare"
68
+ winner_cost, winner = self.ranked[0]
69
+ baseline = next((cost for cost, plan in self.ranked if not plan.label), None)
70
+ if baseline is None or not winner.label:
71
+ return "the baseline shape won"
72
+ saved = baseline.generalized_cost.mid - winner_cost.generalized_cost.mid
73
+ if saved <= 0:
74
+ return "the baseline shape won"
75
+ return f"{winner.label} beats the baseline by {saved:,.0f}"
76
+
77
+
78
+ def run(
79
+ question_query: OracleQuery,
80
+ ask: Ask,
81
+ ctx: PricingContext,
82
+ budget: QueryBudget,
83
+ quote: Quote,
84
+ *,
85
+ admissible: Callable[[TripPlan], bool] | None = None,
86
+ permits: Callable[[int], bool] | None = None,
87
+ probe: bool = True,
88
+ ) -> SearchResult:
89
+ """Phase 0, then the probes the budget can still afford.
90
+
91
+ `quote` is what one query to the chosen oracle costs, already computed by the
92
+ router — so the probe can be *declined* rather than attempted and abandoned
93
+ halfway, which would spend real money for half an answer.
94
+ """
95
+ result = SearchResult()
96
+ keep = admissible or (lambda _plan: True)
97
+
98
+ baseline = [plan for plan in ask(question_query) if keep(plan)]
99
+ budget.charge(quote)
100
+ result.findings.append(
101
+ Finding("baseline", baseline, quote.queries, f"{len(baseline)} priced")
102
+ )
103
+
104
+ if probe and question_query.trip_shape is TripShape.RETURN:
105
+ result.findings.append(
106
+ _return_vs_one_ways(question_query, ask, budget, quote, keep, permits)
107
+ )
108
+
109
+ # The same itinerary on the same tickets is one plan however many phases found
110
+ # it — and the phase that found it first keeps the credit, so a probe is never
111
+ # flattered by rediscovering the baseline.
112
+ everything: list[TripPlan] = []
113
+ seen: set[tuple[str, ...]] = set()
114
+ for finding in result.findings:
115
+ for plan in finding.plans:
116
+ key = tuple(offer.identity_key for offer in plan.offers)
117
+ if key in seen:
118
+ continue
119
+ seen.add(key)
120
+ everything.append(plan)
121
+
122
+ result.ranked = sorted(
123
+ ((price_plan(plan, ctx), plan) for plan in everything),
124
+ key=lambda pair: pair[0].generalized_cost.mid,
125
+ )
126
+ return result
127
+
128
+
129
+ def _return_vs_one_ways(
130
+ query: OracleQuery,
131
+ ask: Ask,
132
+ budget: QueryBudget,
133
+ quote: Quote,
134
+ keep: Callable[[TripPlan], bool],
135
+ permits: Callable[[int], bool] | None,
136
+ ) -> Finding:
137
+ """Two queries to find out whether the round trip was a discount or a markup."""
138
+ both = Quote(quote.oracle_id, quote.queries * 2, quote.money * 2, True, "probe")
139
+ if not budget.can_afford(both):
140
+ budget.note(
141
+ f"return-vs-one-ways skipped: needs {both.queries} queries / "
142
+ f"{both.money:,.2f}, budget has {budget.queries_left} / "
143
+ f"{budget.money_left:,.2f}"
144
+ )
145
+ return Finding("return vs 2 one-ways", [], 0, "not affordable")
146
+
147
+ outbound_query, inbound_query = probes.one_way_queries(query)
148
+ outbound, inbound = ask(outbound_query), ask(inbound_query)
149
+ budget.charge(both)
150
+ if not outbound or not inbound:
151
+ missing = "outbound" if not outbound else "return"
152
+ return Finding(
153
+ "return vs 2 one-ways", [], both.queries, f"no {missing} one-way offered"
154
+ )
155
+
156
+ composed = [
157
+ plan for plan in probes.compose(outbound, inbound, permits=permits) if keep(plan)
158
+ ]
159
+ # No annotation needed here: two directions on two offers meet at the destination
160
+ # with `within_offer` false, and the cost model says what that exposes (§8.7).
161
+ return Finding(
162
+ "return vs 2 one-ways",
163
+ composed,
164
+ both.queries,
165
+ f"{len(outbound)}x{len(inbound)} one-ways composed into {len(composed)}",
166
+ )
@@ -0,0 +1,365 @@
1
+ Metadata-Version: 2.5
2
+ Name: layover
3
+ Version: 0.1.0.dev0
4
+ Summary: Personal flight-search strategist: a budgeted orchestrator over unreliable flight-search oracles
5
+ License: BSD-3-Clause
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: pydantic>=2.7
10
+ Provides-Extra: browser
11
+ Requires-Dist: playwright>=1.44; extra == 'browser'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # layover
15
+
16
+ **A personal flight-search strategist.** Not another search engine — an *orchestrator*
17
+ that treats existing flight sites as expensive, unreliable oracles and spends a bounded
18
+ budget of queries deciding *which* searches are worth making.
19
+
20
+ > **Status: M0 done — it runs.** `layover search trips/example.toml` fetches live
21
+ > Google Flights data, normalizes it to one currency, prices every result under your
22
+ > own valuations and prints a ranked table with the reasoning attached. 199 tests, all
23
+ > offline (a recorded fixture keeps the parser honest). No solver yet: one query per
24
+ > run, no split probes, no date sweep — that is M1. And note that Google answers an EU
25
+ > address with a consent wall, so results today come through a non-EU exit, which
26
+ > means non-home-market prices; every offer records which exit produced it.
27
+
28
+ ## The problem
29
+
30
+ Every flight site covers a slice of the space — virtual interlining on Kiwi, raw coverage
31
+ and calendar pricing on Google Flights, hidden-city on Skiplagged, European LCC one-way
32
+ combinatorics on AZair — and none of them supports the search strategy that actually finds
33
+ cheap long trips:
34
+
35
+ - decompose an itinerary into legs at plausible hubs, and price the legs separately;
36
+ - consider deliberate stopovers instead of connections;
37
+ - compare a return fare against two one-ways, possibly on different carriers, possibly
38
+ bought in different markets;
39
+ - do all of that under a real baggage profile, because a €20 fare with a €45 bag is not
40
+ a €20 fare;
41
+ - and check whether the same query is cheaper from a different point of sale.
42
+
43
+ That is a workflow of a dozen browser tabs and a scratchpad. `layover` automates the
44
+ workflow, not the search.
45
+
46
+ ## How it works
47
+
48
+ Three facts drive the whole design: **prices are non-additive** (a through fare may be
49
+ cheaper *or* dearer than the sum of its legs, and a return may be cheaper *or* dearer than
50
+ two one-ways), **observation is expensive** (every price costs latency and block-risk), and
51
+ **prices are context-dependent** (point of sale changes them). So the system probes rather
52
+ than derives, and the interesting engineering is budget-constrained exploration.
53
+
54
+ A search runs in four phases:
55
+
56
+ | Phase | What happens |
57
+ | --- | --- |
58
+ | 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). |
59
+ | 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. |
60
+ | 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. |
61
+ | 3 — verify | Re-fetch the shortlist and mark each plan `verified / changed / gone`. |
62
+
63
+ ## What it optimizes
64
+
65
+ Not price — **money-equivalent personal utility**, because the cheapest flight is
66
+ routinely the wrong one and there was previously no way to say so. Every plan gets one
67
+ comparable scalar, `generalized_cost`, with the sticker price always displayed beside it:
68
+
69
+ - **Time has a price** (default €25/h, configurable), and not one price: in-flight time
70
+ is discounted, dead layover time is not, short connections are worse than long ones,
71
+ red-eyes cost extra, and a lounge turns a 4-hour wait from €100 of dead time into €35.
72
+ An overnight wait is priced as the cheaper of a terminal night or an airport hotel,
73
+ and the report says which it assumed.
74
+ - **Delay is systematic, so it is priced.** Morning departures leave on time; slip
75
+ accumulates through the afternoon and is worst in the evening. That prior sets both an
76
+ expected cost per segment and the misconnect probability behind every self-transfer —
77
+ and missing the *last* flight of the day costs a hotel and a day, not a rebooking.
78
+ Then the **shortlisted flights get checked against their recent punctuality record**
79
+ and the estimate is blended with the prior, so a habitually-late leg can lose a plan
80
+ its place — small samples nudge, large samples decide.
81
+ - **The search decides what to ask, and says what it spent.** A baseline pass
82
+ establishes the cost to beat; then probes spend what the budget allows on whether a
83
+ different *shape* of purchase does better — the first being return versus two
84
+ one-ways, settled by arithmetic rather than folklore. Probes that cannot learn
85
+ anything are skipped: AZair's answers are already composed one-ways, so asking it
86
+ that question would spend two queries rediscovering what it just said.
87
+ - **A trip is stops and the legs between them.** There is no "outbound" and no
88
+ "return" in the model: an out-and-back is three stops with the holiday in the middle,
89
+ an open jaw or a four-city trip is the same list with different entries, and each
90
+ stop states when to land, how long to stay and when to leave. Round-trip survives one
91
+ layer down, in the *offer* — it is a product airlines sell, and often cheaper than two
92
+ one-ways, which is a probe family rather than a fact about the journey.
93
+ - **Every gap is a stop — a 40-minute transfer and a fortnight are one object.** `n`
94
+ legs have `n − 1` stops, each with its own parameters, and the destination stay is
95
+ simply the one in the middle. What separates a miserable layover from a stopover is
96
+ not length but whether it was *wanted*, which is a valuation, not a class. Three
97
+ numbers describe any of them: the length you want, what that is worth **net** (hotel
98
+ minus enjoyment — one signed number, because a hotel rate is guessable and what Punta
99
+ Cana is worth to you is not), and how fast it sours. A transfer is the same function
100
+ with the value at zero. Wanted stopover time also leaves the time account, and makes
101
+ an airport change *cheap* — you were leaving the airport anyway.
102
+ - **A stop worth having is the only negative cost, and it bounds itself.** A fortnight
103
+ in Punta Cana at −800 is worth +∞ for a decade, because the square eventually swamps
104
+ any offset — so nothing has to cap the holiday, and `max_nights` goes back to meaning
105
+ "I cannot stay past the 30th". The marginal night is the curve's slope: the first
106
+ night past your ideal is nearly free, the tenth is not.
107
+ - **And stops are usually *discovered*, not planned.** You don't want Santorini until a
108
+ Dubai fare turns out to connect there, or Punta Cana until it appears on the way to
109
+ Bogotá. So a long gap is neither buried as dead time nor assumed to be a holiday: it
110
+ becomes a **priced question** — *"this routing sits 50 h in JTR; taking it as two
111
+ nights beats waiting unless the stay costs you more than €200 net"* — with the default
112
+ answer always no, and no question at all for places already on your wishlist.
113
+ - **Flexibility is a price, not a range.** A window is `min` / `ideal` / `max`, and the
114
+ cost between them is quadratic in the distance from the ideal — flat at the bottom,
115
+ steep at the edges, which is how the preference actually behaves. So "±3 days" stops
116
+ meaning "all equally fine", and where the ideal sits inside the window *is* the
117
+ statement about which side is tighter. Any two of the three are enough.
118
+ - **Departure, arrival and length are the same window on three axes.** When you leave,
119
+ when you land, how long you stay — any two fix the third, so you state two and the
120
+ system prices each on its own. Arrival written as clock times (`ideal = 16:00`)
121
+ recurs daily, since "land before midnight" is true of whichever day you land; the
122
+ clock wraps, so "ideally 16:00, never after 01:00" is nine hours of tolerance.
123
+ Lengths use the same curve counted in nights, for the trip and for each stopover —
124
+ an allowance of one-to-three nights in Istanbul is not indifference between one and
125
+ three.
126
+ - **Borders cost time, per traveller.** Some airports transfer you airside; the US makes
127
+ you enter and exit, which is 105 minutes, a bag re-check and an ESTA. Passports are an
128
+ asset the solver spends, choosing the cheapest admissible one per border **for each
129
+ person travelling** — so a routing that is trivial for a HU/GB passport can need a UK
130
+ ETA and a US visa-waiver check for an IT/BR one, and that shows up before booking
131
+ rather than at the gate. Time takes the party's worst case; fees are per person.
132
+ - **Lounge access is a scarce budget.** Each traveller's DragonPass quota (10 free
133
+ entries a year, tracked per person) is shadow-priced — near zero while quota is
134
+ plentiful, rising to the paid rate as it runs out. Access counts only where the lounge
135
+ is in the terminal the plan actually uses, and a party of two spends two entries.
136
+ - **One-off costs are amortized, and both figures shown.** An ESTA is an hour of your
137
+ time and a fee, then it covers two years of trips — so ranking uses the amortized
138
+ share while the report still shows what leaves the account this month. Inside the
139
+ official lead time the routing is infeasible, not merely expensive.
140
+ - **Risk is a probability times a consequence**, and nothing else: no premiums, no
141
+ arbitrary "beat it by 8 %" floor. The hassle of a self-transfer is certain, so it is
142
+ discomfort; the chance of missing it is uncertain, so it is risk. Neither stands in
143
+ for the other.
144
+ - **There is no "trip type" — there are hard constraints with stakes.** A conference
145
+ has a fixed opening session; the way there may be wide open, which is how three days
146
+ in Punta Cana end up in the itinerary. So `arrive_by` / `depart_after` / `home_by`
147
+ are *checked* with a buffer, never priced — and each carries a **criticality**,
148
+ because "definitely arrive by 20:00" cannot be enforced against a timetable alone: a
149
+ plan that intends to land at 14:00 with a 15 % chance of arriving tomorrow satisfies
150
+ the schedule and fails the requirement. Stakes then drive everything a "business
151
+ mode" used to fake: expected violation cost, how much cushion a connection earns,
152
+ and whether a fragile split is worth it. A self-transfer three days before the
153
+ conference is free; the same one the night before is not. Constraints also define
154
+ the slack a discovered stopover may spend.
155
+ - **Comfort is its own price, not a multiple of your hourly rate.** Time (hours you
156
+ don't get back) and discomfort (a broken night, running for a gate, dragging bags
157
+ across a city) are separate families, with a single `scale` knob for "how much do I
158
+ mind things" — because minding airports shouldn't rise with your billing rate.
159
+ - Because some of those numbers are guesses, every report prints the **breakeven rate**
160
+ at which the top two plans swap places, and flags rankings that hinge on an assumption.
161
+
162
+ Judgment calls that belong to *this trip* — is the ESTA still valid, is a 4-hour
163
+ self-transfer at IST fine today, is €35 for a lounge worth it — are **asked, not
164
+ assumed**, and every question carries the pros and cons of each option. Interactive when
165
+ there's a terminal, defaults (all recorded) when there isn't, and over MCP the questions
166
+ are handed to Claude to ask in plain language.
167
+
168
+ Everything the solver sees is normalized first: `Segment` → `Leg` → `Offer` → `TripPlan`,
169
+ one currency, baggage costs attached, risks annotated. Sites sit behind an oracle contract
170
+ with a **capability descriptor**, so the solver plans against capabilities (`date_range_query`,
171
+ `virtual_interline`, `baggage_filter`, `pos_controls`, cost vector) and never against site
172
+ names. Each oracle has a health state machine — `healthy → throttled → cooling → blocked` —
173
+ with token buckets, canary recovery probes, and a visible status surface.
174
+
175
+ Read the docs in this order:
176
+
177
+ 1. [`docs/DESIGN.md`](docs/DESIGN.md) — architecture, intent model, data model, solver,
178
+ baggage-aware totals, **the generalized-cost objective (§8)**, the interaction layer
179
+ (§10), point-of-sale handling, recorded trade-offs, and the settled-decisions log (§13).
180
+ 2. [`docs/ORACLES.md`](docs/ORACLES.md) — oracle contract, browser harness and
181
+ anti-detection, per-site notes, health monitoring, etiquette and legal posture.
182
+ 3. [`docs/ROADMAP.md`](docs/ROADMAP.md) — milestones M0–M6 with acceptance criteria,
183
+ testing strategy, repo layout, open questions.
184
+
185
+ [`CLAUDE.md`](CLAUDE.md) is the short version, for Claude Code and for humans in a hurry.
186
+
187
+ ## Planned shape
188
+
189
+ None of this exists yet; it is here so the target is unambiguous.
190
+
191
+ ```console
192
+ $ layover search trips/bkk-november.toml # budgeted search, ranked by generalized cost
193
+ $ layover search … --non-interactive # never ask; record every assumption used
194
+ $ layover search … --remember # promote an answered question into config
195
+ $ layover status # per-oracle health, today's query spend vs cap
196
+ $ layover knowledge # coverage gaps and cooldowns remembered across runs
197
+ $ layover verify <session> <plan-id> # re-price one plan
198
+ $ layover oracle reset kiwi # after inspecting a blocked adapter
199
+ $ layover gc # drop expired session stores (--all: reports too)
200
+ ```
201
+
202
+ ```toml
203
+ # trips/bkk-november.toml
204
+ origins = ["BUD", "VIE"]
205
+ destinations = ["BKK"]
206
+ trip_shape = "return"
207
+ comfort_scale = 1.0 # how much I mind things, independent of wage
208
+ party = ["owner", "spouse"] # passports & authorizations per traveller
209
+ normalize_currency = "EUR"
210
+
211
+ # earliest possible, what I actually want, latest possible — and what the edge is
212
+ # worth avoiding. Cost = cost_at_tolerance * ((when - ideal) / tolerance)^2, with the
213
+ # tolerance defaulting to the distance from the ideal to each bound.
214
+ [date_windows.outbound]
215
+ min = 2026-11-05
216
+ ideal = 2026-11-07
217
+ max = 2026-11-09
218
+ cost_at_tolerance = 45
219
+
220
+ # times are allowed wherever dates are, and then it is priced in hours
221
+ [date_windows.inbound]
222
+ min = 2026-11-25T06:00:00
223
+ ideal = 2026-11-28T10:00:00
224
+ max = 2026-12-01T23:00:00
225
+ blocked = [2026-11-26] # unavailable, not merely expensive
226
+
227
+ [date_windows.outbound.arrive] # when I want to be there; clock times, so
228
+ ideal = 16:00:00 # it means whichever day I land
229
+ max = 01:00:00
230
+
231
+ [duration] # the same window, counted in nights
232
+ min_nights = 16, ideal_nights = 21, max_nights = 24
233
+
234
+ # hard: checked, never priced — and it defines the slack a stopover may spend
235
+ [[constraints]]
236
+ kind = "arrive_by"
237
+ place = "BKK"
238
+ when = 2026-11-09T09:00:00
239
+ buffer_hours = 12
240
+ label = "workshop opening"
241
+
242
+ criticality = "unmissable" # what missing it would cost: drives cushion and risk
243
+
244
+ # ...or write the journey out: n stops, n-1 legs, no direction anywhere
245
+ # [[stops]]
246
+ # place = "BUD"
247
+ # depart = { min = 2026-11-05, ideal = 2026-11-07, max = 2026-11-09 }
248
+ # [[stops]]
249
+ # place = "FCO"
250
+ # ideal_nights = 3, net_at_ideal = -120 # three days in Rome, net of the hotel
251
+ # arrive = { ideal = 14:00:00, max = 22:00:00 }
252
+
253
+ [stopovers]
254
+ allow = true
255
+ specs = [
256
+ { where = ["IST", "DXB", "SIN"], min_nights = 1, ideal_nights = 2, net_at_ideal = -90 },
257
+ ]
258
+
259
+ baggage_profile = "cabin_bag"
260
+ risk_tolerance = "self_transfer_ok"
261
+ pos_probing = "shortlist_only"
262
+
263
+ [preferences.time] # per-trip override of ~/.config/layover
264
+ wage_rate_per_hour = 25.0
265
+
266
+ [budget]
267
+ max_queries_total = 120
268
+ max_wallclock_minutes = 25
269
+ ```
270
+
271
+ A second interface is the point of the exercise: an **MCP server** exposing
272
+ `search / probe / status / explain / verify`, so Claude can be the natural-language front
273
+ end and iterate conversationally ("hold Istanbul two nights instead"). The CLI comes first
274
+ because it is easier to debug.
275
+
276
+ Planned layout (see `docs/ROADMAP.md` for the full tree):
277
+
278
+ ```
279
+ src/layover/{models,intent,session,currency}.py
280
+ src/layover/solver/ phases, probe scoring, composition, risk
281
+ src/layover/oracles/ base, scheduler, health, api/*, browser/*
282
+ src/layover/pos/ POS identities, proxy exits
283
+ src/layover/report/ terminal + HTML
284
+ flows/*.toml per-site navigation steps and selector maps
285
+ config/, reference/ config; airports, hubs, carrier ancillary fees
286
+ tests/ recorded fixtures + golden parses, MockOracle solver tests
287
+ ```
288
+
289
+ ## Principles that are not up for negotiation
290
+
291
+ 1. **No offer hoarding.** Prices live in a session-scoped SQLite store and are discarded
292
+ when the session ages out. Only small, slow reference data (airports, hubs, ancillary
293
+ fee tables) is versioned in the repo.
294
+ 2. **Query budget = politeness budget.** Hard per-oracle rate limits enforced by the
295
+ scheduler regardless of what the solver wants; duplicate queries are never re-sent.
296
+ 3. **Breakage is a normal event.** Adapters will break. Detect it fast, degrade to other
297
+ oracles, show the state, and keep repair cheap — selectors in TOML, parsers tested
298
+ against recorded fixtures.
299
+ 4. **Total cost, not sticker price.** Always under the user's baggage profile, always in
300
+ one currency, with unknown fees shown as `+ €?..€?` bands and never as zero — and,
301
+ for ranking, always as generalized cost including time, borders and risk.
302
+ 5. **Risk is part of the result.** Self-transfers, split tickets and hidden-city
303
+ itineraries are annotated, and never silently mixed with protected itineraries.
304
+ 6. **Assumptions are visible.** Anything the system had to assume rather than know is
305
+ named in the report with its source, and anything that can strand you is asked.
306
+
307
+ ## Etiquette, ToS and legal posture
308
+
309
+ Stated plainly so that nobody later "optimizes" it away:
310
+
311
+ - **Personal-use volumes only.** The token buckets (browser oracles ~4–6 queries/minute
312
+ burst, ~60–100/day) encode that, and the per-search query budget is a politeness budget.
313
+ - **One in-flight query per oracle**, 2–3 concurrent browser oracles globally. This is an
314
+ etiquette rule, not a performance bug.
315
+ - **No CAPTCHA auto-solving**, no login or paywall circumvention. On a CAPTCHA the oracle
316
+ goes `cooling`, a screenshot lands in the session directory, and traffic routes elsewhere.
317
+ - **No republication** of scraped offer data; session-scoped caching only. Deep links and
318
+ referral mechanisms are respected — they are what the sites want anyway.
319
+ - Scraping consumer sites against their ToS is a grey zone even for personal use. The
320
+ accepted worst case here is **being blocked**, and the system is built to notice and
321
+ degrade gracefully when that happens rather than to evade harder.
322
+
323
+ ## Data and privacy
324
+
325
+ Session stores live under `~/.local/state/layover/sessions/`, age out after 14 days, and are
326
+ removed by `layover gc`. Exactly three things persist across sessions: per-oracle health and
327
+ etiquette state (otherwise backoff is meaningless), browser profile directories, and —
328
+ **opt-in and off by default** — route-level price quantiles (`origin_metro, dest_metro,
329
+ month`) with no offer detail, used only to score probe candidates. HTML reports are treated
330
+ as user-owned documents: they contain prices, they persist, and `gc` leaves them alone unless
331
+ you pass `--all`. Personal data lives in one separate file, `~/.config/layover/travelers.toml` (`0600`,
332
+ gitignored, never in the repo): passports as nationality plus expiry, authorizations,
333
+ DragonPass quotas — and deliberately **no document numbers**, since nothing here books
334
+ anything and the cost model never needs them. Valuations (time rates, premiums, lounge
335
+ pricing) live apart from identity, in `preferences.toml`. One further cache is
336
+ deliberately long-lived: per-flight punctuality history in
337
+ `~/.local/state/layover/punctuality.sqlite`, kept 30 days — it is a fact about a flight, not
338
+ a price, and re-fetching it every session would be pure waste.
339
+
340
+ Scope note: v1 covers **1–2 adults on one shared itinerary** (child fares and per-passenger
341
+ bag maths differ too much between sources to fake), and the repo is private — which is what
342
+ makes committing sanitized site fixtures acceptable.
343
+
344
+ ## Development
345
+
346
+ Python 3.12+, `pydantic` v2, `httpx`, Playwright (anti-detect variant behind a config
347
+ switch), SQLite, TOML config. Tooling is `uv` and `ruff`:
348
+
349
+ ```console
350
+ $ uv sync # create the environment
351
+ $ uv run pytest # offline suite: cost model, feasibility, borders, lounges
352
+ $ uv run ruff check . # lint
353
+ $ uv run ruff format . # format
354
+ ```
355
+
356
+ The suite runs offline against hand-built itineraries and recorded fixtures; live
357
+ queries are opt-in via `pytest -m live` and never run in CI. Preference models forbid
358
+ unknown keys, so a mistyped config key is a startup error rather than a silently
359
+ dropped preference — which is to say, rather than a wrong ranking that looks right.
360
+
361
+ ## Name and license
362
+
363
+ *layover* is a working name — the gap between legs, which this project treats as a
364
+ first-class priced object rather than dead time. Rename freely.
365
+ Licensed under the BSD 3-Clause License; see [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ layover/__init__.py,sha256=SQagvjvvhA5woAodmyrKR2pou7BVgMUH8JpGo1ePW8k,83
2
+ layover/cli.py,sha256=MT4G87VsGx2DykoqUWGuArnUAQBp4v6-QlinCAKIA0A,47789
3
+ layover/currency.py,sha256=redy-wtEzDgfap7sqhutTpoZqJJj1KhW6yn7wRvUYD4,6241
4
+ layover/intent.py,sha256=rTMDYRUKKlhe_KaxufUt70lBscfyofc8W-MRIRtfj6E,45900
5
+ layover/models.py,sha256=Lj2vw5pXz_3wVQS41HHPPlIzeLsI1IfDgw48gEIoILc,11027
6
+ layover/money.py,sha256=M5LmRIAAcu18aWaPxy4DbyIAm6VUoT3Xom7BFobviMk,4169
7
+ layover/session.py,sha256=2djLb-2cSqJcy-1nsjGP-G2woyVVE-EHjFnMPg3_s8w,11924
8
+ layover/cost/__init__.py,sha256=X-lZwwCCqrdcTykJbcO0fotXidY1I7UpqIDgjPUVEPg,73
9
+ layover/cost/borders.py,sha256=2Tl7CH5tVhittDMY-6ImC-7T56x6mgqdRsQQJkJ65BI,16531
10
+ layover/cost/connection.py,sha256=0Ribs5lX1_njthPyhkSsPfKF7W3voC8-rnYN-Mtf8IU,6326
11
+ layover/cost/constraints.py,sha256=togIu4uTlDPmU-XdhyHRtvePCmZtA1sPQYREzeHOfyc,8634
12
+ layover/cost/curve.py,sha256=QIFkpNOJpPv96_uOEi5Q3i02FUhJJw8Q-wtGj2F_qTs,6014
13
+ layover/cost/delay.py,sha256=GZsdFom7q_I361M-Az3F24J1RSSrYT9plCa82XyYsZw,6648
14
+ layover/cost/generalized.py,sha256=pFashwTwFybnvzHifdX8SEDBg8OW_oVyqJcbtIgxSws,52326
15
+ layover/cost/lounge.py,sha256=yjGzNmrxL-jGpN9F1SQ6iIShAfpiW85Lfbjz7Hf8n68,4030
16
+ layover/interact/__init__.py,sha256=zJ8yw_TXNtUwmAFpHJ3LsA9-D5UgdZq1uj51J8XkcEc,56
17
+ layover/interact/assumptions.py,sha256=96TFw57e8xyn-EfPzxR-nEftDwtSrFXL_vlCrz4Das8,4031
18
+ layover/oracles/__init__.py,sha256=3MSfZZKDE-XQE5kqRX63CeOWXFDMBO746fq3O_1Qwt0,78
19
+ layover/oracles/base.py,sha256=lWX2xCfnx1-1eZmg5TzAcQPfR22RlYy5jfbyBQDdaME,8252
20
+ layover/oracles/consent.py,sha256=r2hNkNGUcu_6oYaUOEh-FuTsWaVvyp87LaU3Em2SiUU,8578
21
+ layover/oracles/knowledge.py,sha256=IcsG8WvcK-enYY-Hm0hMBzDeexBVF0NW_u7-8cocRq0,8400
22
+ layover/oracles/record.py,sha256=u3bFSatmxk2t8N_GpurA2K9hzfJlYa1W0oIymU86xcA,3813
23
+ layover/oracles/transport.py,sha256=Ki8HaOHnuaSHem3M06cxoGjPdu4BEZzn6ciPbWjK1o4,15202
24
+ layover/oracles/api/__init__.py,sha256=e-aJ-8B5e1cPyIv4R0XvUwrIkQHGV6SCSRnqyVhoepc,54
25
+ layover/oracles/api/azair.py,sha256=gi_fRKT2_J0KlXIl44Qw9Fs8HX_wo_2q--89Dvr9OBw,18349
26
+ layover/oracles/api/gf_parse.py,sha256=l2ECv9vm4UUwESHpmH5WhUbQJcHWORpeXOwX3DqSMfA,14351
27
+ layover/oracles/api/google_flights.py,sha256=-1FRWESOhYMql_nTRsgPPZ84tCOrCvGhQcp9jZCM8NI,12316
28
+ layover/oracles/api/protobuf.py,sha256=5M6D9Jv7rZ_cC3G9jYagVUKDuLJRHQn1JVet1YLOxUw,3648
29
+ layover/oracles/api/serpapi_flights.py,sha256=4ERS_IbIAhULwm5gBgfYvzU1PBPnLWBFtsdNlH2VdGg,16026
30
+ layover/prefs/__init__.py,sha256=Nhy-zIOHll9WCd6Vq6vXHHrdptOaC0veQlIw-d5pntw,52
31
+ layover/prefs/profile.py,sha256=WomlKFqpcHlulZdbq181hsQdjb995n4YQIoLzNKVph0,14197
32
+ layover/prefs/travelers.py,sha256=kiOmTmAHhNCYi4BA-8Nb8hOZL_hQjPIuXTVqm7fRCsI,4035
33
+ layover/solver/__init__.py,sha256=0YbVi5H31FobbAZwKUKbS8pxhjO-lqCTafr-nEKVN_I,73
34
+ layover/solver/budget.py,sha256=gyebVcCD-rvqlGqE3rBlcUfoOrApGrW_J7Zeui9n4-g,8411
35
+ layover/solver/probes.py,sha256=p6HCxHBOb4xt6jtGpnL-Vv_4iI2xkL2Yj1vWmEUJoRk,4138
36
+ layover/solver/search.py,sha256=xKTizfSvHuEFG0Bws2s4fbFl8peFeIN0Zy__peGhA6c,6214
37
+ layover-0.1.0.dev0.dist-info/METADATA,sha256=bX5NbRjVR642zIgbqrDhpiqti7SPoYsTxVlm6kr_yHg,21314
38
+ layover-0.1.0.dev0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
39
+ layover-0.1.0.dev0.dist-info/entry_points.txt,sha256=0Felowek94rQRNoAK4T2jDSrU3uYC50MGwoOtOjBPjQ,45
40
+ layover-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=fN8KKdDmAODXBl8NeF83bIuVLXzyuH8SuwXSLYpZgr4,1500
41
+ layover-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ layover = layover.cli:main
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Peter Foldiak
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.