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/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]