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/intent.py
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
"""TravelIntent: what the owner asked for (DESIGN §4).
|
|
2
|
+
|
|
3
|
+
A boring, validated structure. Flexibility lives in the date windows, the stopover
|
|
4
|
+
specs and the solver — not in a clever query language. The LLM front end is the
|
|
5
|
+
"flexible expression" layer; this is its compile target.
|
|
6
|
+
|
|
7
|
+
Two things this module is careful about:
|
|
8
|
+
|
|
9
|
+
* **Date windows compile to prices.** `min`/`ideal`/`max` is sugar for a map from
|
|
10
|
+
moment to what departing then costs (§8.4). The map is the real thing, because
|
|
11
|
+
flexibility is neither linear nor symmetric; the default curve is quadratic in the
|
|
12
|
+
distance from `ideal`, so the first hour off target is nearly free and the last one
|
|
13
|
+
before the hard edge is not.
|
|
14
|
+
* **Hard requirements are not preferences.** `[[constraints]]` entries are checked
|
|
15
|
+
and never priced, and each carries the stakes of missing it (§8.1). Nothing here
|
|
16
|
+
says what *kind* of trip this is, because there is no such thing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import tomllib
|
|
22
|
+
from datetime import date, datetime, time, timedelta
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
|
27
|
+
|
|
28
|
+
from layover.cost import curve
|
|
29
|
+
from layover.cost.constraints import ConstraintKind, Criticality, HardConstraint
|
|
30
|
+
from layover.cost.generalized import DateCost
|
|
31
|
+
from layover.models import BaggageProfile, Cabin, TripShape
|
|
32
|
+
|
|
33
|
+
MAX_DATE_COMBINATIONS = 4096
|
|
34
|
+
"""Where enumerating the search space stops and says so (§6)."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_COST_AT_TOLERANCE = 60.0
|
|
37
|
+
"""What the edge of a date window costs before anyone says otherwise.
|
|
38
|
+
|
|
39
|
+
The owner's standing answer lives in `preferences.toml`
|
|
40
|
+
(`[flexibility] date_cost_at_tolerance`) and reaches a window through
|
|
41
|
+
`with_default_scale`; a single trip overrides both by pricing its own window. This
|
|
42
|
+
constant is only what holds until preferences are loaded — deliberately not free and
|
|
43
|
+
not enormous, since the edge of what is possible should be worth avoiding without
|
|
44
|
+
being able to outvote a real difference in fare."""
|
|
45
|
+
|
|
46
|
+
NOON = time(12, 0)
|
|
47
|
+
"""Where a bare `ideal` date sits. A day named without a time means the day, and its
|
|
48
|
+
middle is the only unbiased reading of it."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_date(value: date | datetime) -> date:
|
|
52
|
+
return value.date() if isinstance(value, datetime) else value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _at(value: date | datetime, clock: time, tz=None) -> datetime:
|
|
56
|
+
"""A datetime as given, or the named date at this time of day."""
|
|
57
|
+
if isinstance(value, datetime):
|
|
58
|
+
return value if value.tzinfo or tz is None else value.replace(tzinfo=tz)
|
|
59
|
+
return datetime.combine(value, clock, tzinfo=tz)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _floor(value: date | datetime, tz=None) -> datetime:
|
|
63
|
+
return _at(value, time.min, tz)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ceiling(value: date | datetime, tz=None) -> datetime:
|
|
67
|
+
return _at(value, time.max, tz)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Strict(BaseModel):
|
|
71
|
+
model_config = ConfigDict(extra="forbid")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PlaceSpec(Strict):
|
|
75
|
+
code: str = Field(min_length=3, max_length=4)
|
|
76
|
+
radius_km: float | None = None
|
|
77
|
+
"""Nearby-airport search radius, honoured by oracles that support it."""
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def parse(cls, value: str | dict[str, Any]) -> PlaceSpec:
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return cls(code=value.upper())
|
|
83
|
+
return cls.model_validate({**value, "code": str(value["code"]).upper()})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TimeOfDayWindow(Strict):
|
|
87
|
+
"""The same window, written as clock times — a preference that recurs daily.
|
|
88
|
+
|
|
89
|
+
"Land by 16:00, never after 01:00" is not a statement about one moment. It is true
|
|
90
|
+
of whichever day the flight lands on, and an absolute window cannot say it: pinned
|
|
91
|
+
to a date, it would rule out every departure except one, and widened to cover the
|
|
92
|
+
whole departure range it would stop meaning anything at all.
|
|
93
|
+
|
|
94
|
+
So the axis is the clock. Distances are hours from the ideal, wrapped into ±12, and
|
|
95
|
+
the curve is the one in `cost.curve` — which is why "ideally 16:00, no later than
|
|
96
|
+
01:00" is nine hours of tolerance rather than a contradiction. Midnight is not a
|
|
97
|
+
boundary here; it is just another hour.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
min: time | None = None
|
|
101
|
+
ideal: time | None = None
|
|
102
|
+
max: time | None = None
|
|
103
|
+
cost_at_tolerance: float | None = None
|
|
104
|
+
tolerance_hours: float | None = None
|
|
105
|
+
|
|
106
|
+
@model_validator(mode="after")
|
|
107
|
+
def _check(self) -> TimeOfDayWindow:
|
|
108
|
+
if all(v is None for v in (self.min, self.ideal, self.max)):
|
|
109
|
+
raise ValueError("a time-of-day window needs min, ideal or max")
|
|
110
|
+
if self.ideal is None and self.min is not None and self.max is not None:
|
|
111
|
+
span = _wrap(_hours(self.max) - _hours(self.min))
|
|
112
|
+
middle = (_hours(self.min) + span / 2) % 24
|
|
113
|
+
self.ideal = time(int(middle), int(middle % 1 * 60))
|
|
114
|
+
elif self.ideal is None:
|
|
115
|
+
self.ideal = self.min or self.max
|
|
116
|
+
_ = self.bounds # fail now, not at the first flight that hits it
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def bounds(self) -> curve.Bounds:
|
|
121
|
+
"""Hours from the ideal: negative early, positive late, wrapped through midnight."""
|
|
122
|
+
low = _wrap(_hours(self.min) - _hours(self.ideal)) if self.min else None
|
|
123
|
+
high = _wrap(_hours(self.max) - _hours(self.ideal)) if self.max else None
|
|
124
|
+
if low is not None and low > 0:
|
|
125
|
+
raise ValueError(f"min {self.min} is later in the day than ideal {self.ideal}")
|
|
126
|
+
if high is not None and high < 0:
|
|
127
|
+
raise ValueError(f"max {self.max} is earlier in the day than ideal {self.ideal}")
|
|
128
|
+
return curve.resolve(low, 0.0, high)
|
|
129
|
+
|
|
130
|
+
def cost_of(self, when: datetime) -> float | None:
|
|
131
|
+
scale = self.cost_at_tolerance
|
|
132
|
+
if scale is None:
|
|
133
|
+
scale = DEFAULT_COST_AT_TOLERANCE
|
|
134
|
+
offset = _wrap(_hours(when.time()) - _hours(self.ideal))
|
|
135
|
+
return curve.cost(offset, self.bounds, scale=scale, tolerance=self.tolerance_hours)
|
|
136
|
+
|
|
137
|
+
def priced(self, when: datetime) -> DateCost | None:
|
|
138
|
+
cost = self.cost_of(when)
|
|
139
|
+
return None if cost is None else DateCost(cost, timed=True)
|
|
140
|
+
|
|
141
|
+
def with_default_scale(self, scale: float) -> TimeOfDayWindow:
|
|
142
|
+
if self.cost_at_tolerance is not None:
|
|
143
|
+
return self
|
|
144
|
+
return self.model_copy(update={"cost_at_tolerance": scale})
|
|
145
|
+
|
|
146
|
+
def describe(self) -> str:
|
|
147
|
+
bounds = self.bounds
|
|
148
|
+
return (
|
|
149
|
+
f"{self.min or '—'}..{self.max or '—'} daily, ideally {self.ideal} "
|
|
150
|
+
f"({bounds.low:+.0f}h/{bounds.high:+.0f}h tolerance)"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _hours(value: time) -> float:
|
|
155
|
+
return value.hour + value.minute / 60 + value.second / 3600
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _wrap(hours: float) -> float:
|
|
159
|
+
"""An hour difference as the shortest way round the clock: (-12, +12]."""
|
|
160
|
+
return (hours + 12) % 24 - 12
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class DateWindow(Strict):
|
|
164
|
+
"""When the flight may go, when it ideally goes, and what missing that costs.
|
|
165
|
+
|
|
166
|
+
Three values describe a window — `min`, `ideal`, `max` — and the cost of every
|
|
167
|
+
other moment falls out of them. `min` and `max` are the hard edges: outside them
|
|
168
|
+
there is no window, and the search never asks. `ideal` is the moment that costs
|
|
169
|
+
nothing. Any two of the three are enough, and the missing one is inferred by the
|
|
170
|
+
rules in `cost.curve.resolve` — two bounds and no ideal means the middle, one
|
|
171
|
+
bound and an ideal mirrors the stated side. In between, the default is quadratic
|
|
172
|
+
in the distance from `ideal`, measured in units of a tolerance breadth:
|
|
173
|
+
|
|
174
|
+
cost = cost_at_tolerance × ((when − ideal) / tolerance)²
|
|
175
|
+
|
|
176
|
+
Quadratic rather than linear because that is how the preference actually behaves:
|
|
177
|
+
an hour or a day either side of the target is nearly free (the curve is flat at
|
|
178
|
+
its bottom), while the last day before the hard edge is expensive. A flat rate per
|
|
179
|
+
day says the opposite — that the first day and the last day hurt equally — which is
|
|
180
|
+
the shape that made `deviation_cost_per_day` unsatisfying to write.
|
|
181
|
+
|
|
182
|
+
The tolerance defaults to the distance from `ideal` to the bound *on that side*,
|
|
183
|
+
which makes two useful things true at once: the cost at either edge is exactly
|
|
184
|
+
`cost_at_tolerance`, and asymmetry needs no extra syntax. An `ideal` sitting close to
|
|
185
|
+
`min` and far from `max` is a statement that leaving early is punished sooner than
|
|
186
|
+
leaving late. State `tolerance_days` (or `tolerance_hours`) to decouple the two:
|
|
187
|
+
a breadth smaller than the distance to the bound makes the edges cost more than
|
|
188
|
+
`cost_at_tolerance`, a larger one makes the whole window shallower.
|
|
189
|
+
|
|
190
|
+
**Times, not just dates.** Any of the three may carry a time of day, and then the
|
|
191
|
+
whole window works in hours: "ideally airborne by 09:00, not before 06:00, not
|
|
192
|
+
after 14:00" is one window, not a date plus a separate preference. A bare date
|
|
193
|
+
means the whole day (`min` from 00:00, `max` to 23:59, `ideal` at noon). The
|
|
194
|
+
compiled per-date map then holds the *best* cost obtainable on each day, which is
|
|
195
|
+
what the date-pair enumeration and the oracle window need; `cost_of(datetime)`
|
|
196
|
+
gives the exact price of an actual departure.
|
|
197
|
+
|
|
198
|
+
**Departure and arrival are two windows, not one.** The window itself is about
|
|
199
|
+
when the flight *leaves*; `[date_windows.outbound.arrive]` nests the same shape
|
|
200
|
+
for when it lands. Together with the trip length (`[duration]`) that makes three
|
|
201
|
+
quantities of which any two determine the third, so stating two is normal and
|
|
202
|
+
stating all three is allowed — each is priced on its own, and the report shows
|
|
203
|
+
which ones spoke.
|
|
204
|
+
|
|
205
|
+
**Or say it by hand.** `costs = { 2026-11-05 = 30, ... }` prices named days
|
|
206
|
+
outright, overriding the curve and free to name days outside it. `blocked = [...]`
|
|
207
|
+
removes days entirely — an appointment is not a price. Both are there because no
|
|
208
|
+
curve fits every trip, and the map from moment to money is the real model; the
|
|
209
|
+
three values above are the shorthand that usually fills it in.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
min: date | datetime | None = None
|
|
213
|
+
ideal: date | datetime | None = None
|
|
214
|
+
max: date | datetime | None = None
|
|
215
|
+
cost_at_tolerance: float | None = None
|
|
216
|
+
"""Money charged at the tolerance breadth away from `ideal` — by default, at the
|
|
217
|
+
window's own edge. Per trip rather than per person: how much a day matters varies
|
|
218
|
+
far more between trips than the wage rate does."""
|
|
219
|
+
tolerance_days: float | None = None
|
|
220
|
+
tolerance_hours: float | None = None
|
|
221
|
+
"""Breadth of the quadratic. Defaults to the distance from `ideal` to each bound."""
|
|
222
|
+
|
|
223
|
+
arrive: DateWindow | TimeOfDayWindow | None = None
|
|
224
|
+
"""When this direction should *land*, in exactly the same shape.
|
|
225
|
+
|
|
226
|
+
A separate preference from the departure and often the one that is actually felt:
|
|
227
|
+
nobody minds leaving at 06:00 as much as they mind landing at 02:00.
|
|
228
|
+
|
|
229
|
+
Written with dates it is one absolute window; written with bare clock times
|
|
230
|
+
(`ideal = 16:00:00`) it is a `TimeOfDayWindow` and applies to whichever day the
|
|
231
|
+
flight lands — which is what an arrival preference usually means when the
|
|
232
|
+
departure itself is still free to move."""
|
|
233
|
+
|
|
234
|
+
costs: dict[date, float] = Field(default_factory=dict)
|
|
235
|
+
"""Explicit per-day prices; these override whatever the curve computed."""
|
|
236
|
+
blocked: list[date] = Field(default_factory=list)
|
|
237
|
+
preferred: date | None = None
|
|
238
|
+
"""Output: the cheapest day in the compiled window."""
|
|
239
|
+
|
|
240
|
+
# Older, flatter shorthand, still honoured: a fixed price per day of deviation.
|
|
241
|
+
center: date | None = None
|
|
242
|
+
plus_minus_days: int | None = None
|
|
243
|
+
deviation_cost_per_day: float | None = None
|
|
244
|
+
earlier_cost_per_day: float | None = None
|
|
245
|
+
"""Asymmetry: leaving early and coming back late are rarely the same price."""
|
|
246
|
+
later_cost_per_day: float | None = None
|
|
247
|
+
|
|
248
|
+
_declared: dict[str, Any] = PrivateAttr(default_factory=dict)
|
|
249
|
+
"""The window exactly as it was written, kept so it can be recompiled.
|
|
250
|
+
|
|
251
|
+
Validation turns the shorthand into a map, which is one-way: `costs` afterwards
|
|
252
|
+
holds the *result* of the curve, and feeding that back in would make it explicit
|
|
253
|
+
and freeze it. Resolving a default scale from preferences means compiling again
|
|
254
|
+
from what the owner actually wrote."""
|
|
255
|
+
|
|
256
|
+
@model_validator(mode="before")
|
|
257
|
+
@classmethod
|
|
258
|
+
def _pick_arrival_shape(cls, data: Any) -> Any:
|
|
259
|
+
"""Clock times mean a daily window; dates mean an absolute one.
|
|
260
|
+
|
|
261
|
+
Decided here rather than left to union coercion, because the two models would
|
|
262
|
+
otherwise both half-accept the same input and the loser would be whichever
|
|
263
|
+
pydantic tried first.
|
|
264
|
+
"""
|
|
265
|
+
if not isinstance(data, dict):
|
|
266
|
+
return data
|
|
267
|
+
arrive = data.get("arrive")
|
|
268
|
+
if isinstance(arrive, dict):
|
|
269
|
+
stated = [arrive.get(k) for k in ("min", "ideal", "max")]
|
|
270
|
+
if any(isinstance(v, time) and not isinstance(v, datetime) for v in stated):
|
|
271
|
+
data = {**data, "arrive": TimeOfDayWindow.model_validate(arrive)}
|
|
272
|
+
return data
|
|
273
|
+
|
|
274
|
+
@model_validator(mode="after")
|
|
275
|
+
def _expand(self) -> DateWindow:
|
|
276
|
+
self._declared = {
|
|
277
|
+
name: getattr(self, name)
|
|
278
|
+
for name in (
|
|
279
|
+
"min",
|
|
280
|
+
"ideal",
|
|
281
|
+
"max",
|
|
282
|
+
"arrive",
|
|
283
|
+
"cost_at_tolerance",
|
|
284
|
+
"tolerance_days",
|
|
285
|
+
"tolerance_hours",
|
|
286
|
+
"costs",
|
|
287
|
+
"blocked",
|
|
288
|
+
"preferred",
|
|
289
|
+
"center",
|
|
290
|
+
"plus_minus_days",
|
|
291
|
+
"deviation_cost_per_day",
|
|
292
|
+
"earlier_cost_per_day",
|
|
293
|
+
"later_cost_per_day",
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
if isinstance(self.arrive, DateWindow) and self.arrive.arrive is not None:
|
|
297
|
+
raise ValueError("an arrival window has no arrival of its own")
|
|
298
|
+
if not self._resolve_bounds() and not self.costs:
|
|
299
|
+
raise ValueError("a date window needs min, ideal or max — or `costs`")
|
|
300
|
+
|
|
301
|
+
computed: dict[date, float] = {}
|
|
302
|
+
if self.ideal is not None:
|
|
303
|
+
day = _as_date(self.min)
|
|
304
|
+
while day <= _as_date(self.max):
|
|
305
|
+
computed[day] = self._best_cost_on(day)
|
|
306
|
+
day += timedelta(days=1)
|
|
307
|
+
|
|
308
|
+
computed.update(self.costs) # a stated price wins over any curve
|
|
309
|
+
for day in self.blocked:
|
|
310
|
+
computed.pop(day, None) # unavailable, not merely expensive
|
|
311
|
+
if not computed:
|
|
312
|
+
raise ValueError("every date in this window is blocked")
|
|
313
|
+
|
|
314
|
+
# Costs are relative to the best day and never negative; a window written as a
|
|
315
|
+
# list of absolute prices is rebased only if it would otherwise go below zero.
|
|
316
|
+
floor = min(0.0, min(computed.values()))
|
|
317
|
+
self.costs = {day: round(cost - floor, 6) for day, cost in computed.items()}
|
|
318
|
+
if self.preferred is None or self.preferred not in self.costs:
|
|
319
|
+
self.preferred = min(self.costs, key=lambda d: (self.costs[d], d))
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
def _resolve_bounds(self) -> bool:
|
|
323
|
+
"""Fill in whichever of min/ideal/max were left out. False if none were given.
|
|
324
|
+
|
|
325
|
+
The rules are `cost.curve.resolve`'s, done in calendar arithmetic: mirroring
|
|
326
|
+
happens in whole days for a window that names no times, and in real moments for
|
|
327
|
+
one that does. A dateless window mirrored through midnight would land a day off,
|
|
328
|
+
which is the kind of quiet error a date window must not make.
|
|
329
|
+
"""
|
|
330
|
+
ideal = self.ideal or self.center or self.preferred
|
|
331
|
+
low, high = self.min, self.max
|
|
332
|
+
if ideal is None and low is None and high is None:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
timed = any(isinstance(v, datetime) for v in (low, ideal, high))
|
|
336
|
+
if ideal is None:
|
|
337
|
+
if low is not None and high is not None:
|
|
338
|
+
span = _ceiling(high) - _floor(low)
|
|
339
|
+
middle = _floor(low) + span / 2
|
|
340
|
+
ideal = middle if timed else middle.date()
|
|
341
|
+
else:
|
|
342
|
+
ideal = low if low is not None else high
|
|
343
|
+
if low is None and high is None:
|
|
344
|
+
# Nothing but an ideal: `plus_minus_days` if it was given, else a window
|
|
345
|
+
# of exactly one day, which is a legitimate thing to want.
|
|
346
|
+
span = timedelta(days=self.plus_minus_days or 0)
|
|
347
|
+
low = ideal - span if timed else _as_date(ideal) - span
|
|
348
|
+
high = ideal + span if timed else _as_date(ideal) + span
|
|
349
|
+
if low is None:
|
|
350
|
+
low = (
|
|
351
|
+
_at(ideal, NOON) - (_ceiling(high) - _at(ideal, NOON))
|
|
352
|
+
if timed
|
|
353
|
+
else _as_date(ideal) - (_as_date(high) - _as_date(ideal))
|
|
354
|
+
)
|
|
355
|
+
if high is None:
|
|
356
|
+
high = (
|
|
357
|
+
_at(ideal, NOON) + (_at(ideal, NOON) - _floor(low))
|
|
358
|
+
if timed
|
|
359
|
+
else _as_date(ideal) + (_as_date(ideal) - _as_date(low))
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if _floor(low) > _ceiling(high):
|
|
363
|
+
raise ValueError(f"window is empty: min {low} is after max {high}")
|
|
364
|
+
if not _floor(low) <= _at(ideal, NOON) <= _ceiling(high):
|
|
365
|
+
raise ValueError(f"ideal {ideal} lies outside min {low}..max {high}")
|
|
366
|
+
self.min, self.ideal, self.max = low, ideal, high
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
# ── the curve ────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def has_time(self) -> bool:
|
|
373
|
+
"""Whether this window is about moments or about days."""
|
|
374
|
+
return any(isinstance(v, datetime) for v in (self.min, self.ideal, self.max))
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def linear(self) -> bool:
|
|
378
|
+
"""The older shorthand: a flat price per day of deviation, in either direction."""
|
|
379
|
+
return (
|
|
380
|
+
self.deviation_cost_per_day is not None
|
|
381
|
+
or self.earlier_cost_per_day is not None
|
|
382
|
+
or self.later_cost_per_day is not None
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _bounds(self) -> tuple[datetime, datetime, datetime]:
|
|
386
|
+
"""min, ideal, max as moments, in whatever timezone the window speaks."""
|
|
387
|
+
tz = self.tzinfo
|
|
388
|
+
return (
|
|
389
|
+
_at(self.min, time.min, tz),
|
|
390
|
+
_at(self.ideal, NOON, tz),
|
|
391
|
+
_at(self.max, time.max, tz),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _span(self, earlier: bool) -> float:
|
|
395
|
+
"""Distance from the ideal to one bound, in the window's own unit."""
|
|
396
|
+
low, ideal, high = self._bounds()
|
|
397
|
+
if self.has_time:
|
|
398
|
+
gap = (ideal - low) if earlier else (high - ideal)
|
|
399
|
+
return gap.total_seconds() / 3600.0
|
|
400
|
+
# A window that names no times is about days, and its edges are whole days
|
|
401
|
+
# away from the ideal — not 11 hours and 59 minutes away.
|
|
402
|
+
bound = self.min if earlier else self.max
|
|
403
|
+
return float(abs((_as_date(self.ideal) - _as_date(bound)).days))
|
|
404
|
+
|
|
405
|
+
def _tolerance(self, earlier: bool) -> float:
|
|
406
|
+
"""The breadth of the curve, which is the span unless it was stated.
|
|
407
|
+
|
|
408
|
+
Distinct from the span on purpose: the bound says what is *possible*, the
|
|
409
|
+
tolerance says how fast it gets expensive on the way there."""
|
|
410
|
+
if self.tolerance_hours is not None:
|
|
411
|
+
return self.tolerance_hours / (1.0 if self.has_time else 24.0)
|
|
412
|
+
if self.tolerance_days is not None:
|
|
413
|
+
return self.tolerance_days * (24.0 if self.has_time else 1.0)
|
|
414
|
+
return self._span(earlier)
|
|
415
|
+
|
|
416
|
+
def _deviation(self, when: datetime) -> float:
|
|
417
|
+
"""How far from `ideal`, in the window's own unit.
|
|
418
|
+
|
|
419
|
+
A window that names times is measured in hours, because that is the resolution
|
|
420
|
+
it was written at. One that names only days is measured in whole days: asking
|
|
421
|
+
it about 05:40 would otherwise invent a preference it never expressed.
|
|
422
|
+
"""
|
|
423
|
+
_, ideal, _ = self._bounds()
|
|
424
|
+
if self.has_time:
|
|
425
|
+
return (when - ideal).total_seconds() / 3600.0
|
|
426
|
+
return float((when.date() - _as_date(self.ideal)).days)
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def tzinfo(self):
|
|
430
|
+
"""The window's own timezone, if it stated one.
|
|
431
|
+
|
|
432
|
+
A window written without an offset — "ideal = 2026-10-01T10:00:00" — means
|
|
433
|
+
local wall-clock time at the airport, which is how every timetable is read.
|
|
434
|
+
Departure times arrive here timezone-aware, so they are compared on their own
|
|
435
|
+
wall clock unless the window explicitly says otherwise.
|
|
436
|
+
"""
|
|
437
|
+
for value in (self.ideal, self.min, self.max):
|
|
438
|
+
if isinstance(value, datetime) and value.tzinfo is not None:
|
|
439
|
+
return value.tzinfo
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
def _align(self, when: datetime) -> datetime:
|
|
443
|
+
if self.tzinfo is None:
|
|
444
|
+
return when.replace(tzinfo=None)
|
|
445
|
+
return when if when.tzinfo else when.replace(tzinfo=self.tzinfo)
|
|
446
|
+
|
|
447
|
+
def cost_at(self, when: datetime) -> float | None:
|
|
448
|
+
"""What departing at this moment costs. None means outside the window."""
|
|
449
|
+
when = self._align(when)
|
|
450
|
+
low, _, high = self._bounds()
|
|
451
|
+
if not low <= when <= high:
|
|
452
|
+
return None
|
|
453
|
+
delta = self._deviation(when)
|
|
454
|
+
if self.linear:
|
|
455
|
+
rate = self.earlier_cost_per_day if delta < 0 else self.later_cost_per_day
|
|
456
|
+
if rate is None:
|
|
457
|
+
rate = self.deviation_cost_per_day or 0.0
|
|
458
|
+
return abs(delta) * (rate / 24.0 if self.has_time else rate)
|
|
459
|
+
scale = self.cost_at_tolerance
|
|
460
|
+
if scale is None:
|
|
461
|
+
scale = DEFAULT_COST_AT_TOLERANCE
|
|
462
|
+
# In the window's own unit, so the shared curve knows nothing about dates.
|
|
463
|
+
bounds = curve.Bounds(low=-self._span(earlier=True), ideal=0.0, high=self._span(False))
|
|
464
|
+
return curve.cost(
|
|
465
|
+
delta, bounds, scale=scale, tolerance=self._tolerance(earlier=delta < 0)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _best_cost_on(self, day: date) -> float:
|
|
469
|
+
"""The cheapest moment available on this day.
|
|
470
|
+
|
|
471
|
+
A timed window cannot be summarized by one number per day without choosing
|
|
472
|
+
which moment stands for the day — and the only defensible choice is the best
|
|
473
|
+
one available, since the search is free to pick it. `cost_at` still prices the
|
|
474
|
+
departure that actually turns up.
|
|
475
|
+
"""
|
|
476
|
+
low, ideal, high = self._bounds()
|
|
477
|
+
first = max(low, datetime.combine(day, time.min, tzinfo=self.tzinfo))
|
|
478
|
+
last = min(high, datetime.combine(day, time.max, tzinfo=self.tzinfo))
|
|
479
|
+
best = min(max(ideal, first), last) # the moment nearest `ideal` that day
|
|
480
|
+
return self.cost_at(best) or 0.0
|
|
481
|
+
|
|
482
|
+
# ── reading it back ──────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def dates(self) -> list[date]:
|
|
486
|
+
return sorted(self.costs)
|
|
487
|
+
|
|
488
|
+
def cost_of(self, when: date | datetime) -> float | None:
|
|
489
|
+
"""None means outside the window — not merely expensive.
|
|
490
|
+
|
|
491
|
+
A `date` is answered from the compiled map (the best that day); a `datetime`
|
|
492
|
+
is priced exactly, which is the difference between "Tuesday is fine" and
|
|
493
|
+
"Tuesday at 05:40 is not what I meant".
|
|
494
|
+
"""
|
|
495
|
+
if isinstance(when, datetime):
|
|
496
|
+
return self.cost_at(when) if self._align(when).date() in self.costs else None
|
|
497
|
+
return self.costs.get(when)
|
|
498
|
+
|
|
499
|
+
def with_default_scale(self, scale: float) -> DateWindow:
|
|
500
|
+
"""This window, priced at `scale` if it did not name a scale of its own.
|
|
501
|
+
|
|
502
|
+
Intents are loaded before preferences are, so a window that says nothing about
|
|
503
|
+
what its edges are worth is compiled against a module fallback and then told
|
|
504
|
+
the owner's own number here. A window that priced itself is returned untouched
|
|
505
|
+
— the trip-specific statement outranks the standing one.
|
|
506
|
+
"""
|
|
507
|
+
declared = dict(self._declared)
|
|
508
|
+
if self.arrive is not None:
|
|
509
|
+
declared["arrive"] = self.arrive.with_default_scale(scale)
|
|
510
|
+
if self.cost_at_tolerance is None and not self.linear:
|
|
511
|
+
declared["cost_at_tolerance"] = scale
|
|
512
|
+
elif declared.get("arrive") is self.arrive:
|
|
513
|
+
return self
|
|
514
|
+
return DateWindow(**declared)
|
|
515
|
+
|
|
516
|
+
def priced(self, when: date | datetime) -> DateCost | None:
|
|
517
|
+
"""The cost, carrying whether this window resolves times as well as days."""
|
|
518
|
+
cost = self.cost_of(when)
|
|
519
|
+
return None if cost is None else DateCost(cost, self.has_time)
|
|
520
|
+
|
|
521
|
+
def describe(self) -> str:
|
|
522
|
+
"""The window in one line, for the assumptions block."""
|
|
523
|
+
stamp = "%Y-%m-%d %H:%M" if self.has_time else "%Y-%m-%d"
|
|
524
|
+
span = f"{_floor(self.min):{stamp}}..{_ceiling(self.max):{stamp}}"
|
|
525
|
+
scale = self.cost_at_tolerance or DEFAULT_COST_AT_TOLERANCE
|
|
526
|
+
shape = (
|
|
527
|
+
f"{self.deviation_cost_per_day or self.later_cost_per_day or 0:.0f}/day"
|
|
528
|
+
if self.linear
|
|
529
|
+
else f"quadratic, {scale:.0f} at the tolerance"
|
|
530
|
+
)
|
|
531
|
+
ideal = f"{_at(self.ideal, NOON):{stamp}}" if self.ideal else str(self.preferred)
|
|
532
|
+
blocked = f", {len(self.blocked)} blocked" if self.blocked else ""
|
|
533
|
+
return f"{span}, ideal {ideal} ({shape}{blocked})"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class LengthWindow(Strict):
|
|
537
|
+
"""How long a stay should be — the same window, counted in nights (§8.4).
|
|
538
|
+
|
|
539
|
+
`min` / `ideal` / `max` and the same quadratic between them, because "five to nine
|
|
540
|
+
nights, ideally seven" is the same kind of statement as "the 22nd to the 27th,
|
|
541
|
+
ideally the 24th". Any two of the three are enough; the third is inferred by
|
|
542
|
+
`cost.curve.resolve`, so "between five and nine" alone means seven is the middle
|
|
543
|
+
and both ends cost the same.
|
|
544
|
+
|
|
545
|
+
The bounds are hard, as everywhere else: `permits` rejects a length outside them
|
|
546
|
+
rather than pricing it, since a window states what is possible, not what is dear.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
min_nights: int | None = None
|
|
550
|
+
ideal_nights: int | None = None
|
|
551
|
+
max_nights: int | None = None
|
|
552
|
+
cost_at_tolerance: float | None = None
|
|
553
|
+
tolerance_nights: float | None = None
|
|
554
|
+
net_at_ideal: float = 0.0
|
|
555
|
+
"""What a stay of the ideal length is worth, net, as one signed number.
|
|
556
|
+
|
|
557
|
+
Negative means you would pay for it; positive means it is a chore. Net because we
|
|
558
|
+
can guess a hotel rate and cannot guess what the beaches of Punta Cana are worth
|
|
559
|
+
to a particular person — and only the difference ever enters the ranking (§8.3).
|
|
560
|
+
|
|
561
|
+
This is the one place in the model where a cost may go below zero, and the reason
|
|
562
|
+
it is safe: the square eventually swamps any offset, so a stay worth −800 for a
|
|
563
|
+
fortnight is worth +∞ for a decade. The curve bounds itself; `max_nights` is then
|
|
564
|
+
a statement of fact rather than a device to stop the solver running away.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
def permits(self, nights: int) -> bool:
|
|
568
|
+
if self.min_nights is not None and nights < self.min_nights:
|
|
569
|
+
return False
|
|
570
|
+
return not (self.max_nights is not None and nights > self.max_nights)
|
|
571
|
+
|
|
572
|
+
@property
|
|
573
|
+
def bounds(self) -> curve.Bounds | None:
|
|
574
|
+
stated = (self.min_nights, self.ideal_nights, self.max_nights)
|
|
575
|
+
if all(value is None for value in stated):
|
|
576
|
+
return None
|
|
577
|
+
# An ideal with no bounds spans one tolerance either side rather than
|
|
578
|
+
# collapsing to a single night. A lone date means one day and that is right —
|
|
579
|
+
# you leave on a day — but a stay of "ideally a week" that priced only exactly
|
|
580
|
+
# seven nights would silently drop its own valuation for every other length.
|
|
581
|
+
return curve.resolve(*stated, spread=self.tolerance_nights)
|
|
582
|
+
|
|
583
|
+
def cost_of(self, nights: int, *, scale: float | None = None) -> float | None:
|
|
584
|
+
"""What a stay of this length costs. None when nothing was stated to price it."""
|
|
585
|
+
bounds = self.bounds
|
|
586
|
+
amount = self.cost_at_tolerance if scale is None else (self.cost_at_tolerance or scale)
|
|
587
|
+
if bounds is None or amount is None:
|
|
588
|
+
return None
|
|
589
|
+
return curve.cost(
|
|
590
|
+
nights,
|
|
591
|
+
bounds,
|
|
592
|
+
scale=amount,
|
|
593
|
+
tolerance=self.tolerance_nights,
|
|
594
|
+
offset=self.net_at_ideal,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def breakeven(self, *, scale: float | None = None) -> tuple[float, float] | None:
|
|
598
|
+
"""The range of lengths over which this stop pays for itself (§8.3)."""
|
|
599
|
+
bounds = self.bounds
|
|
600
|
+
amount = self.cost_at_tolerance if scale is None else (self.cost_at_tolerance or scale)
|
|
601
|
+
if bounds is None or not amount:
|
|
602
|
+
return None
|
|
603
|
+
return curve.breakeven(
|
|
604
|
+
bounds, scale=amount, tolerance=self.tolerance_nights, offset=self.net_at_ideal
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def describe(self) -> str:
|
|
608
|
+
bounds = self.bounds
|
|
609
|
+
if bounds is None:
|
|
610
|
+
return "any length"
|
|
611
|
+
worth = f", worth {-self.net_at_ideal:g} at best" if self.net_at_ideal < 0 else ""
|
|
612
|
+
return f"{bounds.low:g}-{bounds.high:g}n, ideally {bounds.ideal:g}n{worth}"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class DurationSpec(LengthWindow):
|
|
616
|
+
"""The trip's own length. `preferred_nights` is the older name for `ideal_nights`."""
|
|
617
|
+
|
|
618
|
+
preferred_nights: int | None = None
|
|
619
|
+
|
|
620
|
+
@model_validator(mode="after")
|
|
621
|
+
def _one_ideal(self) -> DurationSpec:
|
|
622
|
+
if self.preferred_nights is not None and self.ideal_nights is None:
|
|
623
|
+
self.ideal_nights = self.preferred_nights
|
|
624
|
+
elif self.ideal_nights is not None and self.preferred_nights is None:
|
|
625
|
+
self.preferred_nights = self.ideal_nights
|
|
626
|
+
return self
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class StopoverSpec(LengthWindow):
|
|
630
|
+
"""A stopover: where, how long, and what a night there is worth.
|
|
631
|
+
|
|
632
|
+
Its length is a window like any other — one or three nights in Istanbul are not
|
|
633
|
+
the same thing, and "ideally two" is exactly the statement the quadratic exists to
|
|
634
|
+
carry (§8.3, §8.4).
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
where: str | list[str] = "any_enroute_hub"
|
|
638
|
+
min_nights: int = 1
|
|
639
|
+
max_nights: int = 3
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def places(self) -> list[str] | None:
|
|
643
|
+
"""None means "resolve from the routing" (§8.3 candidates)."""
|
|
644
|
+
if isinstance(self.where, str):
|
|
645
|
+
return None if self.where == "any_enroute_hub" else [self.where.upper()]
|
|
646
|
+
return [p.upper() for p in self.where]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class StopSpec(LengthWindow):
|
|
650
|
+
"""A place you are at, and everything you asked for about being there.
|
|
651
|
+
|
|
652
|
+
A journey is stops and the legs between them: `n` stops make `n − 1` legs, and
|
|
653
|
+
every gap between two legs is one of these — a 40-minute transfer, a two-night
|
|
654
|
+
stopover and the destination stay alike. There is no type distinction, only a
|
|
655
|
+
valuation, and a place you never mention gets the default, which is a wash (§8.3).
|
|
656
|
+
|
|
657
|
+
Each stop carries the same three windows as everything else: when the leg into it
|
|
658
|
+
should land (`arrive`), how long to stay (the inherited length window), and when
|
|
659
|
+
the leg out of it should leave (`depart`). Any two fix the third, so stating two is
|
|
660
|
+
normal. The first stop has no arrival and the last has no departure, because you
|
|
661
|
+
start at home and end there.
|
|
662
|
+
|
|
663
|
+
Addressed by **place**, never by position: the intent is written before any routing
|
|
664
|
+
exists, and the search compares itineraries with different leg counts, so "the gap
|
|
665
|
+
after leg 2" means something different in each candidate.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
place: str = Field(min_length=3, max_length=4)
|
|
669
|
+
arrive: DateWindow | TimeOfDayWindow | None = None
|
|
670
|
+
depart: DateWindow | TimeOfDayWindow | None = None
|
|
671
|
+
|
|
672
|
+
@model_validator(mode="before")
|
|
673
|
+
@classmethod
|
|
674
|
+
def _pick_shapes(cls, data: Any) -> Any:
|
|
675
|
+
if isinstance(data, dict):
|
|
676
|
+
for key in ("arrive", "depart"):
|
|
677
|
+
window = data.get(key)
|
|
678
|
+
if isinstance(window, dict):
|
|
679
|
+
data = {**data, key: _window_from(window)}
|
|
680
|
+
return data
|
|
681
|
+
|
|
682
|
+
@model_validator(mode="after")
|
|
683
|
+
def _upper(self) -> StopSpec:
|
|
684
|
+
self.place = self.place.upper()
|
|
685
|
+
return self
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _window_from(raw: dict[str, Any]) -> DateWindow | TimeOfDayWindow:
|
|
689
|
+
"""Clock times mean a window that recurs daily; dates mean an absolute one."""
|
|
690
|
+
stated = [raw.get(k) for k in ("min", "ideal", "max")]
|
|
691
|
+
if any(isinstance(v, time) and not isinstance(v, datetime) for v in stated):
|
|
692
|
+
return TimeOfDayWindow.model_validate(raw)
|
|
693
|
+
return DateWindow.model_validate(raw)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class StopoverPolicy(Strict):
|
|
697
|
+
allow: bool = True
|
|
698
|
+
specs: list[StopoverSpec] = Field(default_factory=list)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class ConstraintSpec(Strict):
|
|
702
|
+
"""The TOML form of a hard requirement (§8.1)."""
|
|
703
|
+
|
|
704
|
+
kind: ConstraintKind
|
|
705
|
+
when: datetime
|
|
706
|
+
place: str | None = None
|
|
707
|
+
buffer_hours: float = 12.0
|
|
708
|
+
criticality: Criticality = Criticality.SERIOUS
|
|
709
|
+
violation_cost: float | None = None
|
|
710
|
+
label: str = ""
|
|
711
|
+
|
|
712
|
+
def to_constraint(self) -> HardConstraint:
|
|
713
|
+
return HardConstraint(
|
|
714
|
+
kind=self.kind,
|
|
715
|
+
when=self.when,
|
|
716
|
+
place=self.place.upper() if self.place else None,
|
|
717
|
+
buffer_hours=self.buffer_hours,
|
|
718
|
+
criticality=self.criticality,
|
|
719
|
+
violation_cost=self.violation_cost,
|
|
720
|
+
label=self.label,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class LegSpec(Strict):
|
|
725
|
+
"""Per-leg valuation overrides — a few numbers, not a trip type (§8.1)."""
|
|
726
|
+
|
|
727
|
+
preferences: dict[str, Any] = Field(default_factory=dict)
|
|
728
|
+
note: str = ""
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
class BudgetSpec(Strict):
|
|
732
|
+
max_queries_total: int = 120
|
|
733
|
+
max_queries_per_oracle: int | None = None
|
|
734
|
+
max_wallclock_minutes: float = 25.0
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class TravelIntent(Strict):
|
|
738
|
+
origins: list[PlaceSpec] = Field(min_length=1)
|
|
739
|
+
destinations: list[PlaceSpec] = Field(min_length=1)
|
|
740
|
+
trip_shape: TripShape = TripShape.RETURN
|
|
741
|
+
outbound: DateWindow | None = None
|
|
742
|
+
"""When the first leg leaves. Optional when the journey says it itself."""
|
|
743
|
+
inbound: DateWindow | None = None
|
|
744
|
+
duration: DurationSpec = Field(default_factory=DurationSpec)
|
|
745
|
+
stops: list[StopSpec] = Field(default_factory=list)
|
|
746
|
+
"""Named stops with their own length windows — the destination included (§8.3)."""
|
|
747
|
+
stopovers: StopoverPolicy = Field(default_factory=StopoverPolicy)
|
|
748
|
+
constraints: list[ConstraintSpec] = Field(default_factory=list)
|
|
749
|
+
party: list[str] = Field(default_factory=lambda: ["owner"])
|
|
750
|
+
cabin: Cabin = Cabin.ECONOMY
|
|
751
|
+
baggage_profile: BaggageProfile = BaggageProfile.CABIN_BAG
|
|
752
|
+
preferences: dict[str, Any] = Field(default_factory=dict)
|
|
753
|
+
"""Per-trip overrides of the valuation profile."""
|
|
754
|
+
legs: dict[int, LegSpec] = Field(default_factory=dict)
|
|
755
|
+
pos_probing: str = "shortlist_only"
|
|
756
|
+
budget: BudgetSpec = Field(default_factory=BudgetSpec)
|
|
757
|
+
normalize_currency: str = "EUR"
|
|
758
|
+
notes: str = ""
|
|
759
|
+
|
|
760
|
+
@model_validator(mode="after")
|
|
761
|
+
def _check_shape(self) -> TravelIntent:
|
|
762
|
+
if (
|
|
763
|
+
self.trip_shape is TripShape.RETURN
|
|
764
|
+
and self.inbound is None
|
|
765
|
+
and self.duration.min_nights is None
|
|
766
|
+
):
|
|
767
|
+
raise ValueError(
|
|
768
|
+
"a return trip needs either an `inbound` window or a `duration` "
|
|
769
|
+
"(otherwise there is nothing to search over)"
|
|
770
|
+
)
|
|
771
|
+
if self.trip_shape is TripShape.ONE_WAY and self.inbound is not None:
|
|
772
|
+
raise ValueError("a one-way trip cannot have an inbound window")
|
|
773
|
+
return self
|
|
774
|
+
|
|
775
|
+
@property
|
|
776
|
+
def hard_constraints(self) -> list[HardConstraint]:
|
|
777
|
+
return [c.to_constraint() for c in self.constraints]
|
|
778
|
+
|
|
779
|
+
@model_validator(mode="after")
|
|
780
|
+
def _windows_from_the_journey(self) -> TravelIntent:
|
|
781
|
+
"""A journey written out in full already states its own departure windows.
|
|
782
|
+
|
|
783
|
+
Rather than have two sources of truth, the directional names are *derived* from
|
|
784
|
+
the journey when it supplies them: `outbound` is the leg out of home, `inbound`
|
|
785
|
+
the leg out of the last stop before home. Everything downstream — the oracles,
|
|
786
|
+
the date-pair projection, the report — keeps speaking the language sites speak.
|
|
787
|
+
"""
|
|
788
|
+
written_out = self.stops and self.stops[0].place == self.origins[0].code
|
|
789
|
+
if written_out:
|
|
790
|
+
if self.outbound is None:
|
|
791
|
+
self.outbound = self.stops[0].depart
|
|
792
|
+
if self.inbound is None and len(self.stops) > 2:
|
|
793
|
+
self.inbound = self.stops[-2].depart
|
|
794
|
+
if self.outbound is None:
|
|
795
|
+
raise ValueError(
|
|
796
|
+
"a journey needs somewhere to start: give [date_windows.outbound], "
|
|
797
|
+
"or a first stop with a `depart` window"
|
|
798
|
+
)
|
|
799
|
+
return self
|
|
800
|
+
|
|
801
|
+
@property
|
|
802
|
+
def journey(self) -> list[StopSpec]:
|
|
803
|
+
"""The trip as places in order, whichever way it was written (§8.3).
|
|
804
|
+
|
|
805
|
+
There is no outbound and no inbound. A return trip is three stops — home, the
|
|
806
|
+
destination, home — and an open jaw or a four-city trip is the same list with
|
|
807
|
+
different entries. `origins`/`destinations`/`date_windows`/`duration` remain a
|
|
808
|
+
perfectly good way to say the common case, and compile to exactly this:
|
|
809
|
+
|
|
810
|
+
[BUD depart=outbound]
|
|
811
|
+
[LIS arrive=outbound.arrive, length=duration, depart=inbound]
|
|
812
|
+
[BUD arrive=inbound.arrive]
|
|
813
|
+
|
|
814
|
+
Stated stops slot into the middle, so `[[stops]]` alone gives a multi-city
|
|
815
|
+
journey without repeating the endpoints.
|
|
816
|
+
"""
|
|
817
|
+
home = self.origins[0].code
|
|
818
|
+
middle = list(self.stops)
|
|
819
|
+
if not middle:
|
|
820
|
+
middle = [
|
|
821
|
+
StopSpec(
|
|
822
|
+
place=self.destinations[0].code,
|
|
823
|
+
min_nights=self.duration.min_nights,
|
|
824
|
+
ideal_nights=self.duration.ideal_nights,
|
|
825
|
+
max_nights=self.duration.max_nights,
|
|
826
|
+
cost_at_tolerance=self.duration.cost_at_tolerance,
|
|
827
|
+
tolerance_nights=self.duration.tolerance_nights,
|
|
828
|
+
)
|
|
829
|
+
]
|
|
830
|
+
if middle[0].place == home: # the journey was written out in full
|
|
831
|
+
stops = middle
|
|
832
|
+
else:
|
|
833
|
+
stops = [StopSpec(place=home), *middle]
|
|
834
|
+
if self.trip_shape is not TripShape.ONE_WAY:
|
|
835
|
+
stops.append(StopSpec(place=home))
|
|
836
|
+
|
|
837
|
+
# `[duration]` is a length window for the destination stay, so a stop that
|
|
838
|
+
# named itself but not its length still inherits the bounds — otherwise
|
|
839
|
+
# writing `[[stops]]` would silently drop "five to nine nights".
|
|
840
|
+
destinations = {place.code for place in self.destinations}
|
|
841
|
+
for stop in stops[1:-1] if len(stops) > 2 else stops[1:]:
|
|
842
|
+
if stop.place not in destinations:
|
|
843
|
+
continue
|
|
844
|
+
for field_name in ("min_nights", "ideal_nights", "max_nights"):
|
|
845
|
+
if getattr(stop, field_name) is None:
|
|
846
|
+
setattr(stop, field_name, getattr(self.duration, field_name))
|
|
847
|
+
|
|
848
|
+
# The older surface names the same windows; it just names them by direction.
|
|
849
|
+
if stops[0].depart is None:
|
|
850
|
+
stops[0].depart = self.outbound
|
|
851
|
+
if len(stops) > 1 and stops[1].arrive is None:
|
|
852
|
+
stops[1].arrive = self.outbound.arrive
|
|
853
|
+
if self.inbound is not None and len(stops) > 2:
|
|
854
|
+
if stops[-2].depart is None:
|
|
855
|
+
stops[-2].depart = self.inbound
|
|
856
|
+
if stops[-1].arrive is None:
|
|
857
|
+
stops[-1].arrive = self.inbound.arrive
|
|
858
|
+
return stops
|
|
859
|
+
|
|
860
|
+
@property
|
|
861
|
+
def stopover_requests(self) -> dict[str, StopoverSpec | StopSpec]:
|
|
862
|
+
"""Named places the intent asked for, by airport code.
|
|
863
|
+
|
|
864
|
+
`any_enroute_hub` specs are not here: they are resolved against the routing
|
|
865
|
+
during the search, which is where discovered stops come from (§8.3). A
|
|
866
|
+
`[[stops]]` entry wins over a `[stopovers]` spec for the same place, being the
|
|
867
|
+
more specific way of saying it.
|
|
868
|
+
"""
|
|
869
|
+
out: dict[str, StopoverSpec | StopSpec] = {}
|
|
870
|
+
if self.stopovers.allow:
|
|
871
|
+
for spec in self.stopovers.specs:
|
|
872
|
+
for place in spec.places or ():
|
|
873
|
+
out[place] = spec
|
|
874
|
+
for stop in self.stops:
|
|
875
|
+
out[stop.place] = stop
|
|
876
|
+
return out
|
|
877
|
+
|
|
878
|
+
def departure_sets(self, *, limit: int = MAX_DATE_COMBINATIONS) -> list[tuple[date, ...]]:
|
|
879
|
+
"""Every combination of leg departure dates the journey allows.
|
|
880
|
+
|
|
881
|
+
One entry per leg, in order, so a three-stop trip yields pairs and a four-stop
|
|
882
|
+
one yields triples. Each stop's length window filters the combinations as they
|
|
883
|
+
are built rather than after: with `k` legs the product is exponential, and
|
|
884
|
+
generating it in full before filtering is how an intent with wide windows turns
|
|
885
|
+
into an out-of-memory error instead of a budget warning.
|
|
886
|
+
|
|
887
|
+
This is the search space the solver spends its budget over, so it is also the
|
|
888
|
+
first place a badly-specified intent shows up — `limit` truncates rather than
|
|
889
|
+
letting a five-city trip with three-week windows enumerate for an hour.
|
|
890
|
+
"""
|
|
891
|
+
stops = self.journey
|
|
892
|
+
legs = [stop.depart for stop in stops[:-1]]
|
|
893
|
+
if any(window is None for window in legs):
|
|
894
|
+
return []
|
|
895
|
+
|
|
896
|
+
combinations: list[tuple[date, ...]] = [()]
|
|
897
|
+
for index, window in enumerate(legs):
|
|
898
|
+
grown: list[tuple[date, ...]] = []
|
|
899
|
+
for prefix in combinations:
|
|
900
|
+
for day in window.dates: # type: ignore[union-attr]
|
|
901
|
+
if prefix:
|
|
902
|
+
nights = (day - prefix[-1]).days
|
|
903
|
+
# The stop between these two legs has to be a real stay of an
|
|
904
|
+
# allowed length; anything else is a different trip.
|
|
905
|
+
if nights <= 0 or not stops[index].permits(nights):
|
|
906
|
+
continue
|
|
907
|
+
grown.append((*prefix, day))
|
|
908
|
+
if len(grown) >= limit:
|
|
909
|
+
break
|
|
910
|
+
if len(grown) >= limit:
|
|
911
|
+
break
|
|
912
|
+
combinations = grown
|
|
913
|
+
return combinations
|
|
914
|
+
|
|
915
|
+
def date_pairs(self) -> list[tuple[date, date | None]]:
|
|
916
|
+
"""The two-leg view of `departure_sets`, which is what the oracles can ask.
|
|
917
|
+
|
|
918
|
+
Sites sell one-ways and round trips; a four-city journey is not a query anyone
|
|
919
|
+
can send. So the journey is the model and this is the projection of it that the
|
|
920
|
+
adapters speak (DESIGN §8.3).
|
|
921
|
+
"""
|
|
922
|
+
sets = self.departure_sets()
|
|
923
|
+
if len(self.journey) < 3:
|
|
924
|
+
return [(days[0], None) for days in sets]
|
|
925
|
+
return [(days[0], days[-1]) for days in sets]
|
|
926
|
+
|
|
927
|
+
def stop_windows(self):
|
|
928
|
+
"""The journey in the shape the cost model wants: place plus two callables."""
|
|
929
|
+
from layover.cost.generalized import StopWindows
|
|
930
|
+
|
|
931
|
+
return [
|
|
932
|
+
StopWindows(
|
|
933
|
+
place=stop.place,
|
|
934
|
+
arrive=stop.arrive.priced if stop.arrive else None,
|
|
935
|
+
depart=stop.depart.priced if stop.depart else None,
|
|
936
|
+
)
|
|
937
|
+
for stop in self.journey
|
|
938
|
+
]
|
|
939
|
+
|
|
940
|
+
def with_flexibility_default(
|
|
941
|
+
self, scale: float, length_scale: float | None = None
|
|
942
|
+
) -> TravelIntent:
|
|
943
|
+
"""The intent with any unpriced window resolved against preferences.
|
|
944
|
+
|
|
945
|
+
Moments and lengths take different scales, since "a day later than I wanted to
|
|
946
|
+
leave" and "a night longer than I wanted to stay" are not the same regret.
|
|
947
|
+
"""
|
|
948
|
+
update = {
|
|
949
|
+
"outbound": self.outbound.with_default_scale(scale),
|
|
950
|
+
"inbound": self.inbound.with_default_scale(scale) if self.inbound else None,
|
|
951
|
+
}
|
|
952
|
+
if length_scale is not None and self.duration.cost_at_tolerance is None:
|
|
953
|
+
update["duration"] = self.duration.model_copy(
|
|
954
|
+
update={"cost_at_tolerance": length_scale}
|
|
955
|
+
)
|
|
956
|
+
return self.model_copy(update=update)
|
|
957
|
+
|
|
958
|
+
def date_costs(self) -> dict[date, float]:
|
|
959
|
+
"""Both windows merged into one map, as the cost model expects (§8.4).
|
|
960
|
+
|
|
961
|
+
Day granularity: each date is worth what its best departure moment is worth.
|
|
962
|
+
`departure_cost` is the sharper instrument when the actual time is known.
|
|
963
|
+
"""
|
|
964
|
+
costs = dict(self.outbound.costs)
|
|
965
|
+
if self.inbound is not None:
|
|
966
|
+
costs.update(self.inbound.costs)
|
|
967
|
+
return costs
|
|
968
|
+
|
|
969
|
+
def arrival_cost(self, when: datetime) -> DateCost | None:
|
|
970
|
+
"""When the outbound should land, if the intent said."""
|
|
971
|
+
window = self.outbound.arrive
|
|
972
|
+
return window.priced(when) if window else None
|
|
973
|
+
|
|
974
|
+
def return_departure_cost(self, when: datetime) -> DateCost | None:
|
|
975
|
+
return self.inbound.priced(when) if self.inbound else None
|
|
976
|
+
|
|
977
|
+
def return_arrival_cost(self, when: datetime) -> DateCost | None:
|
|
978
|
+
"""When to be home again — usually the one that is actually promised."""
|
|
979
|
+
window = self.inbound.arrive if self.inbound else None
|
|
980
|
+
return window.priced(when) if window else None
|
|
981
|
+
|
|
982
|
+
def duration_cost(self, nights: int) -> float | None:
|
|
983
|
+
return self.duration.cost_of(nights)
|
|
984
|
+
|
|
985
|
+
def departure_cost(self, when: datetime) -> DateCost | None:
|
|
986
|
+
"""What leaving at this exact moment costs, under whichever window covers it.
|
|
987
|
+
|
|
988
|
+
The inbound window wins where the two overlap, matching `date_costs`. A moment
|
|
989
|
+
neither window covers returns None, which the cost model reads as "this window
|
|
990
|
+
has nothing to say" rather than as free.
|
|
991
|
+
"""
|
|
992
|
+
return self.outbound.priced(when)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def load_intent(path: Path) -> TravelIntent:
|
|
996
|
+
"""Read an intent file. Unknown keys are errors, not silently ignored."""
|
|
997
|
+
raw = tomllib.loads(Path(path).read_text())
|
|
998
|
+
raw["origins"] = [PlaceSpec.parse(v) for v in _as_list(raw.get("origins", []))]
|
|
999
|
+
raw["destinations"] = [PlaceSpec.parse(v) for v in _as_list(raw.get("destinations", []))]
|
|
1000
|
+
|
|
1001
|
+
windows = raw.pop("date_windows", None)
|
|
1002
|
+
if windows:
|
|
1003
|
+
raw.setdefault("outbound", windows.get("outbound"))
|
|
1004
|
+
if windows.get("inbound") is not None:
|
|
1005
|
+
raw.setdefault("inbound", windows["inbound"])
|
|
1006
|
+
|
|
1007
|
+
legs = raw.get("legs")
|
|
1008
|
+
if isinstance(legs, dict):
|
|
1009
|
+
raw["legs"] = {int(k): v for k, v in legs.items()}
|
|
1010
|
+
return TravelIntent.model_validate(raw)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _as_list(value: Any) -> list[Any]:
|
|
1014
|
+
if isinstance(value, list):
|
|
1015
|
+
return value
|
|
1016
|
+
return [value]
|