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.
- layover-0.1.0.dev0/.claude/settings.local.json +24 -0
- layover-0.1.0.dev0/.gitignore +30 -0
- layover-0.1.0.dev0/CLAUDE.md +66 -0
- layover-0.1.0.dev0/LICENSE +28 -0
- layover-0.1.0.dev0/PKG-INFO +365 -0
- layover-0.1.0.dev0/README.md +352 -0
- layover-0.1.0.dev0/captures/brazil-20260805-171233.json +42 -0
- layover-0.1.0.dev0/captures/uae-20260805-173135.json +36 -0
- layover-0.1.0.dev0/captures/uae-20260805-173238.json +36 -0
- layover-0.1.0.dev0/captures/uae-20260805-173905.json +56 -0
- layover-0.1.0.dev0/captures/united-arab-emirates-20260805-172858.json +36 -0
- layover-0.1.0.dev0/captures/us-20260805-171905.json +56 -0
- layover-0.1.0.dev0/captures/us-reconstructed-20260805-155125.json +27 -0
- layover-0.1.0.dev0/captures/us-verified-20260805-172153.json +56 -0
- layover-0.1.0.dev0/config/layover.toml +141 -0
- layover-0.1.0.dev0/config/pos.toml +73 -0
- layover-0.1.0.dev0/config/preferences.toml +272 -0
- layover-0.1.0.dev0/config/travelers.example.toml +54 -0
- layover-0.1.0.dev0/docs/DESIGN.md +1363 -0
- layover-0.1.0.dev0/docs/ORACLES.md +373 -0
- layover-0.1.0.dev0/docs/ROADMAP.md +453 -0
- layover-0.1.0.dev0/pyproject.toml +59 -0
- layover-0.1.0.dev0/reference/authorizations.toml +92 -0
- layover-0.1.0.dev0/reference/delay_profiles.toml +42 -0
- layover-0.1.0.dev0/reference/departure_taxes.toml +71 -0
- layover-0.1.0.dev0/reference/transit_regimes.toml +138 -0
- layover-0.1.0.dev0/src/layover/__init__.py +3 -0
- layover-0.1.0.dev0/src/layover/cli.py +1137 -0
- layover-0.1.0.dev0/src/layover/cost/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/cost/borders.py +449 -0
- layover-0.1.0.dev0/src/layover/cost/connection.py +173 -0
- layover-0.1.0.dev0/src/layover/cost/constraints.py +213 -0
- layover-0.1.0.dev0/src/layover/cost/curve.py +145 -0
- layover-0.1.0.dev0/src/layover/cost/delay.py +173 -0
- layover-0.1.0.dev0/src/layover/cost/generalized.py +1207 -0
- layover-0.1.0.dev0/src/layover/cost/lounge.py +110 -0
- layover-0.1.0.dev0/src/layover/currency.py +166 -0
- layover-0.1.0.dev0/src/layover/intent.py +1016 -0
- layover-0.1.0.dev0/src/layover/interact/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/interact/assumptions.py +120 -0
- layover-0.1.0.dev0/src/layover/models.py +363 -0
- layover-0.1.0.dev0/src/layover/money.py +112 -0
- layover-0.1.0.dev0/src/layover/oracles/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/oracles/api/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/oracles/api/azair.py +480 -0
- layover-0.1.0.dev0/src/layover/oracles/api/gf_parse.py +344 -0
- layover-0.1.0.dev0/src/layover/oracles/api/google_flights.py +322 -0
- layover-0.1.0.dev0/src/layover/oracles/api/protobuf.py +110 -0
- layover-0.1.0.dev0/src/layover/oracles/api/serpapi_flights.py +415 -0
- layover-0.1.0.dev0/src/layover/oracles/base.py +224 -0
- layover-0.1.0.dev0/src/layover/oracles/consent.py +199 -0
- layover-0.1.0.dev0/src/layover/oracles/knowledge.py +208 -0
- layover-0.1.0.dev0/src/layover/oracles/record.py +98 -0
- layover-0.1.0.dev0/src/layover/oracles/transport.py +429 -0
- layover-0.1.0.dev0/src/layover/prefs/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/prefs/profile.py +333 -0
- layover-0.1.0.dev0/src/layover/prefs/travelers.py +125 -0
- layover-0.1.0.dev0/src/layover/session.py +328 -0
- layover-0.1.0.dev0/src/layover/solver/__init__.py +1 -0
- layover-0.1.0.dev0/src/layover/solver/budget.py +208 -0
- layover-0.1.0.dev0/src/layover/solver/probes.py +105 -0
- layover-0.1.0.dev0/src/layover/solver/search.py +166 -0
- layover-0.1.0.dev0/tests/__init__.py +0 -0
- layover-0.1.0.dev0/tests/conftest.py +150 -0
- layover-0.1.0.dev0/tests/fixtures/azair/bud-lis-flexi.html.gz +0 -0
- layover-0.1.0.dev0/tests/fixtures/azair/bud-lis-flexi.meta.json +11 -0
- layover-0.1.0.dev0/tests/fixtures/google_flights/bud-bkk-return.html.gz +0 -0
- layover-0.1.0.dev0/tests/fixtures/google_flights/bud-bkk-return.meta.json +19 -0
- layover-0.1.0.dev0/tests/fixtures/serpapi/bud-bkk-return.json.gz +0 -0
- layover-0.1.0.dev0/tests/fixtures/serpapi/bud-bkk-return.meta.json +16 -0
- layover-0.1.0.dev0/tests/test_azair.py +340 -0
- layover-0.1.0.dev0/tests/test_borders.py +197 -0
- layover-0.1.0.dev0/tests/test_budget.py +153 -0
- layover-0.1.0.dev0/tests/test_connection.py +180 -0
- layover-0.1.0.dev0/tests/test_constraints.py +275 -0
- layover-0.1.0.dev0/tests/test_currency.py +110 -0
- layover-0.1.0.dev0/tests/test_curve.py +160 -0
- layover-0.1.0.dev0/tests/test_delay.py +92 -0
- layover-0.1.0.dev0/tests/test_generalized.py +983 -0
- layover-0.1.0.dev0/tests/test_gf_parse.py +176 -0
- layover-0.1.0.dev0/tests/test_google_flights.py +443 -0
- layover-0.1.0.dev0/tests/test_intent.py +628 -0
- layover-0.1.0.dev0/tests/test_knowledge.py +159 -0
- layover-0.1.0.dev0/tests/test_lounge.py +120 -0
- layover-0.1.0.dev0/tests/test_money.py +74 -0
- layover-0.1.0.dev0/tests/test_preferences.py +104 -0
- layover-0.1.0.dev0/tests/test_serpapi.py +344 -0
- layover-0.1.0.dev0/tests/test_session.py +121 -0
- layover-0.1.0.dev0/tests/test_solver.py +232 -0
- layover-0.1.0.dev0/trips/bangkok.toml +24 -0
- layover-0.1.0.dev0/trips/example.toml +84 -0
- layover-0.1.0.dev0/trips/multi-city.toml +41 -0
- 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).
|