layover 0.1.0.dev0__tar.gz

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.
Files changed (93) hide show
  1. layover-0.1.0.dev0/.claude/settings.local.json +24 -0
  2. layover-0.1.0.dev0/.gitignore +30 -0
  3. layover-0.1.0.dev0/CLAUDE.md +66 -0
  4. layover-0.1.0.dev0/LICENSE +28 -0
  5. layover-0.1.0.dev0/PKG-INFO +365 -0
  6. layover-0.1.0.dev0/README.md +352 -0
  7. layover-0.1.0.dev0/captures/brazil-20260805-171233.json +42 -0
  8. layover-0.1.0.dev0/captures/uae-20260805-173135.json +36 -0
  9. layover-0.1.0.dev0/captures/uae-20260805-173238.json +36 -0
  10. layover-0.1.0.dev0/captures/uae-20260805-173905.json +56 -0
  11. layover-0.1.0.dev0/captures/united-arab-emirates-20260805-172858.json +36 -0
  12. layover-0.1.0.dev0/captures/us-20260805-171905.json +56 -0
  13. layover-0.1.0.dev0/captures/us-reconstructed-20260805-155125.json +27 -0
  14. layover-0.1.0.dev0/captures/us-verified-20260805-172153.json +56 -0
  15. layover-0.1.0.dev0/config/layover.toml +141 -0
  16. layover-0.1.0.dev0/config/pos.toml +73 -0
  17. layover-0.1.0.dev0/config/preferences.toml +272 -0
  18. layover-0.1.0.dev0/config/travelers.example.toml +54 -0
  19. layover-0.1.0.dev0/docs/DESIGN.md +1363 -0
  20. layover-0.1.0.dev0/docs/ORACLES.md +373 -0
  21. layover-0.1.0.dev0/docs/ROADMAP.md +453 -0
  22. layover-0.1.0.dev0/pyproject.toml +59 -0
  23. layover-0.1.0.dev0/reference/authorizations.toml +92 -0
  24. layover-0.1.0.dev0/reference/delay_profiles.toml +42 -0
  25. layover-0.1.0.dev0/reference/departure_taxes.toml +71 -0
  26. layover-0.1.0.dev0/reference/transit_regimes.toml +138 -0
  27. layover-0.1.0.dev0/src/layover/__init__.py +3 -0
  28. layover-0.1.0.dev0/src/layover/cli.py +1137 -0
  29. layover-0.1.0.dev0/src/layover/cost/__init__.py +1 -0
  30. layover-0.1.0.dev0/src/layover/cost/borders.py +449 -0
  31. layover-0.1.0.dev0/src/layover/cost/connection.py +173 -0
  32. layover-0.1.0.dev0/src/layover/cost/constraints.py +213 -0
  33. layover-0.1.0.dev0/src/layover/cost/curve.py +145 -0
  34. layover-0.1.0.dev0/src/layover/cost/delay.py +173 -0
  35. layover-0.1.0.dev0/src/layover/cost/generalized.py +1207 -0
  36. layover-0.1.0.dev0/src/layover/cost/lounge.py +110 -0
  37. layover-0.1.0.dev0/src/layover/currency.py +166 -0
  38. layover-0.1.0.dev0/src/layover/intent.py +1016 -0
  39. layover-0.1.0.dev0/src/layover/interact/__init__.py +1 -0
  40. layover-0.1.0.dev0/src/layover/interact/assumptions.py +120 -0
  41. layover-0.1.0.dev0/src/layover/models.py +363 -0
  42. layover-0.1.0.dev0/src/layover/money.py +112 -0
  43. layover-0.1.0.dev0/src/layover/oracles/__init__.py +1 -0
  44. layover-0.1.0.dev0/src/layover/oracles/api/__init__.py +1 -0
  45. layover-0.1.0.dev0/src/layover/oracles/api/azair.py +480 -0
  46. layover-0.1.0.dev0/src/layover/oracles/api/gf_parse.py +344 -0
  47. layover-0.1.0.dev0/src/layover/oracles/api/google_flights.py +322 -0
  48. layover-0.1.0.dev0/src/layover/oracles/api/protobuf.py +110 -0
  49. layover-0.1.0.dev0/src/layover/oracles/api/serpapi_flights.py +415 -0
  50. layover-0.1.0.dev0/src/layover/oracles/base.py +224 -0
  51. layover-0.1.0.dev0/src/layover/oracles/consent.py +199 -0
  52. layover-0.1.0.dev0/src/layover/oracles/knowledge.py +208 -0
  53. layover-0.1.0.dev0/src/layover/oracles/record.py +98 -0
  54. layover-0.1.0.dev0/src/layover/oracles/transport.py +429 -0
  55. layover-0.1.0.dev0/src/layover/prefs/__init__.py +1 -0
  56. layover-0.1.0.dev0/src/layover/prefs/profile.py +333 -0
  57. layover-0.1.0.dev0/src/layover/prefs/travelers.py +125 -0
  58. layover-0.1.0.dev0/src/layover/session.py +328 -0
  59. layover-0.1.0.dev0/src/layover/solver/__init__.py +1 -0
  60. layover-0.1.0.dev0/src/layover/solver/budget.py +208 -0
  61. layover-0.1.0.dev0/src/layover/solver/probes.py +105 -0
  62. layover-0.1.0.dev0/src/layover/solver/search.py +166 -0
  63. layover-0.1.0.dev0/tests/__init__.py +0 -0
  64. layover-0.1.0.dev0/tests/conftest.py +150 -0
  65. layover-0.1.0.dev0/tests/fixtures/azair/bud-lis-flexi.html.gz +0 -0
  66. layover-0.1.0.dev0/tests/fixtures/azair/bud-lis-flexi.meta.json +11 -0
  67. layover-0.1.0.dev0/tests/fixtures/google_flights/bud-bkk-return.html.gz +0 -0
  68. layover-0.1.0.dev0/tests/fixtures/google_flights/bud-bkk-return.meta.json +19 -0
  69. layover-0.1.0.dev0/tests/fixtures/serpapi/bud-bkk-return.json.gz +0 -0
  70. layover-0.1.0.dev0/tests/fixtures/serpapi/bud-bkk-return.meta.json +16 -0
  71. layover-0.1.0.dev0/tests/test_azair.py +340 -0
  72. layover-0.1.0.dev0/tests/test_borders.py +197 -0
  73. layover-0.1.0.dev0/tests/test_budget.py +153 -0
  74. layover-0.1.0.dev0/tests/test_connection.py +180 -0
  75. layover-0.1.0.dev0/tests/test_constraints.py +275 -0
  76. layover-0.1.0.dev0/tests/test_currency.py +110 -0
  77. layover-0.1.0.dev0/tests/test_curve.py +160 -0
  78. layover-0.1.0.dev0/tests/test_delay.py +92 -0
  79. layover-0.1.0.dev0/tests/test_generalized.py +983 -0
  80. layover-0.1.0.dev0/tests/test_gf_parse.py +176 -0
  81. layover-0.1.0.dev0/tests/test_google_flights.py +443 -0
  82. layover-0.1.0.dev0/tests/test_intent.py +628 -0
  83. layover-0.1.0.dev0/tests/test_knowledge.py +159 -0
  84. layover-0.1.0.dev0/tests/test_lounge.py +120 -0
  85. layover-0.1.0.dev0/tests/test_money.py +74 -0
  86. layover-0.1.0.dev0/tests/test_preferences.py +104 -0
  87. layover-0.1.0.dev0/tests/test_serpapi.py +344 -0
  88. layover-0.1.0.dev0/tests/test_session.py +121 -0
  89. layover-0.1.0.dev0/tests/test_solver.py +232 -0
  90. layover-0.1.0.dev0/trips/bangkok.toml +24 -0
  91. layover-0.1.0.dev0/trips/example.toml +84 -0
  92. layover-0.1.0.dev0/trips/multi-city.toml +41 -0
  93. layover-0.1.0.dev0/uv.lock +409 -0
@@ -0,0 +1,24 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python3 *)",
5
+ "Bash(uv --version)",
6
+ "Bash(curl -fsSL https://astral.sh/uv/install.sh)",
7
+ "Bash(timeout 120 sh)",
8
+ "WebSearch",
9
+ "WebFetch(domain:developers.amadeus.com)",
10
+ "WebFetch(domain:serpapi.com)",
11
+ "Bash(python *)",
12
+ "Bash(uv run *)",
13
+ "Bash(git add *)",
14
+ "Bash(git commit *)",
15
+ "Bash(gh repo *)",
16
+ "Bash(git fetch *)",
17
+ "Bash(git push *)",
18
+ "Bash(unzip -l \"Attachments-initial test records.zip\")",
19
+ "Bash(unzip -o \"/home/test/projects/layover/wintest/Attachments-initial test records.zip\" -d wintest)",
20
+ "Bash(printf '\\\\n# Windows test protocols — working notes, deliberately untracked\\\\nWINDOWS_TESZT*.md\\\\nwintest/\\\\n')",
21
+ "Bash(git check-ignore *)"
22
+ ]
23
+ }
24
+ }
@@ -0,0 +1,30 @@
1
+ # Personal data — never committed (see docs/DESIGN.md §13.11)
2
+ travelers.toml
3
+ cookies.toml
4
+ .env
5
+ .env.*
6
+
7
+ # Session state, caches, reports (see docs/DESIGN.md §13.12)
8
+ *.sqlite
9
+ *.sqlite-wal
10
+ *.sqlite-shm
11
+ reports/
12
+ sessions/
13
+
14
+ # Browser profiles and captured evidence
15
+ profiles/
16
+ screenshots/
17
+
18
+ # Python
19
+ __pycache__/
20
+ *.py[cod]
21
+ .venv/
22
+ venv/
23
+ *.egg-info/
24
+ dist/
25
+ build/
26
+ .pytest_cache/
27
+ .ruff_cache/
28
+ .mypy_cache/
29
+ .coverage
30
+ htmlcov/
@@ -0,0 +1,66 @@
1
+ # layover — flight search strategist
2
+
3
+ *(Working name; rename freely. "layover" = the gap between legs, which this project
4
+ prices as a first-class object rather than treating as dead time.)*
5
+
6
+ ## What this project is
7
+
8
+ A personal flight-search **orchestrator**, not a flight-search engine. It automates the
9
+ owner's manual workflow: run baseline searches on existing sites, extract the route/hub
10
+ structure from the results, then spend a bounded budget of follow-up queries probing
11
+ variations — split tickets, stopovers, return vs. two one-ways, baggage variants,
12
+ point-of-sale (country/currency/IP) variants — and finally verify a shortlist on
13
+ authoritative sources.
14
+
15
+ Existing flight sites and APIs are treated as **expensive, unreliable oracles**. The
16
+ intelligence of this system is in *deciding which queries to make*, not in accumulating
17
+ data.
18
+
19
+ ## Read order
20
+
21
+ 1. `docs/DESIGN.md` — architecture, core concepts, intent model, data model, solver.
22
+ 2. `docs/ORACLES.md` — oracle adapter contract, browser automation & anti-detection,
23
+ health monitoring / circuit breakers, point-of-sale handling, per-site notes, etiquette.
24
+ 3. `docs/ROADMAP.md` — milestones with acceptance criteria, testing strategy, repo
25
+ layout, open questions.
26
+
27
+ ## Non-negotiable principles
28
+
29
+ 1. **No offer hoarding.** Offer/price data lives only in a session-scoped store and is
30
+ discarded (or aggregated into tiny anonymous priors, opt-in) when the session ends.
31
+ Small, slowly-changing *reference* data (airport metadata, carrier ancillary fee
32
+ tables, hub lists) is fine and is versioned in the repo.
33
+ 2. **Query budget = politeness budget.** Every oracle has hard rate limits enforced by
34
+ the scheduler regardless of what the solver wants. Getting blocked occasionally is
35
+ acceptable; hammering a site is not. Duplicate queries within a session must never
36
+ be re-sent (session cache dedup).
37
+ 3. **Breakage is a normal event, not an error.** Site adapters will break. The system
38
+ must detect breakage/blocking quickly, degrade gracefully to other oracles, surface
39
+ the state visibly, and make adapter repair cheap (selectors in config, fixture tests).
40
+ 4. **Total cost, not sticker price.** Comparisons are always made under the user's
41
+ baggage profile and in one normalization currency. A fare without its ancillary cost
42
+ is not a result. Ranking goes further: plans are ordered by **generalized cost**
43
+ (money + time at a configurable hourly rate + delay expectation + border time +
44
+ flexibility + risk premium — DESIGN §8), with sticker price always displayed too.
45
+ 5. **Risk is part of the result.** Split tickets, self-transfers, and hidden-city
46
+ itineraries carry operational risk; every plan is annotated, never silently mixed
47
+ with protected itineraries.
48
+ 6. **Assumptions are visible, and the ones that can strand you are asked.** Judgment
49
+ calls that vary per trip (valid ESTA? acceptable self-transfer? business or holiday?)
50
+ go through the interaction layer (DESIGN §10): asked when interactive, defaulted and
51
+ recorded when not, never silently baked in.
52
+
53
+ ## Tech stack (defaults, change with justification)
54
+
55
+ - Python 3.12+, `pydantic` v2 models, `httpx` for HTTP oracles, `playwright` (with
56
+ anti-detection variant, see ORACLES.md) for browser oracles, SQLite for the session
57
+ store, TOML config, `pytest` with recorded-fixture tests.
58
+ - Interfaces: CLI first; then an MCP server (FastMCP) so Claude can drive searches
59
+ conversationally; HTML/terminal report output.
60
+
61
+ ## Owner context
62
+
63
+ Single user, flies often, based in Hungary (BUD; VIE/BTS in reach), comfortable with
64
+ Python, runs Ubuntu. Personal-use query volumes only. The owner explicitly accepts that
65
+ scraped sources may throttle or block occasionally and wants that *monitored and
66
+ visible*, not hidden.
@@ -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.
@@ -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).