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.
- layover/__init__.py +3 -0
- layover/cli.py +1137 -0
- layover/cost/__init__.py +1 -0
- layover/cost/borders.py +449 -0
- layover/cost/connection.py +173 -0
- layover/cost/constraints.py +213 -0
- layover/cost/curve.py +145 -0
- layover/cost/delay.py +173 -0
- layover/cost/generalized.py +1207 -0
- layover/cost/lounge.py +110 -0
- layover/currency.py +166 -0
- layover/intent.py +1016 -0
- layover/interact/__init__.py +1 -0
- layover/interact/assumptions.py +120 -0
- layover/models.py +363 -0
- layover/money.py +112 -0
- layover/oracles/__init__.py +1 -0
- layover/oracles/api/__init__.py +1 -0
- layover/oracles/api/azair.py +480 -0
- layover/oracles/api/gf_parse.py +344 -0
- layover/oracles/api/google_flights.py +322 -0
- layover/oracles/api/protobuf.py +110 -0
- layover/oracles/api/serpapi_flights.py +415 -0
- layover/oracles/base.py +224 -0
- layover/oracles/consent.py +199 -0
- layover/oracles/knowledge.py +208 -0
- layover/oracles/record.py +98 -0
- layover/oracles/transport.py +429 -0
- layover/prefs/__init__.py +1 -0
- layover/prefs/profile.py +333 -0
- layover/prefs/travelers.py +125 -0
- layover/session.py +328 -0
- layover/solver/__init__.py +1 -0
- layover/solver/budget.py +208 -0
- layover/solver/probes.py +105 -0
- layover/solver/search.py +166 -0
- layover-0.1.0.dev0.dist-info/METADATA +365 -0
- layover-0.1.0.dev0.dist-info/RECORD +41 -0
- layover-0.1.0.dev0.dist-info/WHEEL +4 -0
- layover-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- layover-0.1.0.dev0.dist-info/licenses/LICENSE +28 -0
layover/solver/search.py
ADDED
|
@@ -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,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.
|