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.
@@ -0,0 +1,213 @@
1
+ """Hard constraints, kept separate from soft preferences (DESIGN §8.1).
2
+
3
+ The old model had one switch per trip — business or holiday — and real trips are
4
+ not like that. A conference in Bogotá starts at a fixed hour, which is as hard as a
5
+ constraint gets; but the way *there* was wide open, which is how a few days in Punta
6
+ Cana ended up in the itinerary. One trip, strict at one end and exploratory at the
7
+ other.
8
+
9
+ So the two kinds of requirement are modelled separately and never mixed:
10
+
11
+ * **Hard constraints** (this module) are facts about the world: be in Bogotá before
12
+ the conference opens, be home before Monday, do not depart before the last exam.
13
+ They are checked, not priced. A plan that violates one is not an expensive plan —
14
+ it is not a plan.
15
+ * **Soft preferences** (`prefs/profile.py`) are prices: what an hour, a night, or a
16
+ day of slippage is worth. They can differ per leg, because time on the way to
17
+ something can be worth more than time on the way home.
18
+
19
+ A constraint also carries **stakes** (`criticality`). That is what makes "definitely
20
+ arrive by 20:00" mean something: a schedule check alone passes any plan that intends
21
+ to be on time, however likely it is to slip. Stakes turn that probability into money,
22
+ which is what the old business/holiday switch was crudely approximating.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timedelta
29
+ from enum import StrEnum
30
+
31
+ from layover.models import TripPlan
32
+
33
+
34
+ class ConstraintKind(StrEnum):
35
+ ARRIVE_BY = "arrive_by"
36
+ """Be at this place, on the ground, before this moment. The conference."""
37
+
38
+ DEPART_AFTER = "depart_after"
39
+ """Cannot leave before this: a exam, a handover, a delivery."""
40
+
41
+ HOME_BY = "home_by"
42
+ """Be back at the origin before this. Monday morning, usually."""
43
+
44
+
45
+ class Criticality(StrEnum):
46
+ """How bad it is to miss this. The whole point of the class.
47
+
48
+ A schedule check alone treats "arrive by 20:00" as satisfied by any plan that
49
+ *intends* to arrive at 14:00 — including one with a 15% chance of arriving
50
+ tomorrow. Stakes are what make "definitely" mean something: they turn a
51
+ probability of missing into a cost, so a risky plan loses on the objective
52
+ instead of needing a mode flag to forbid it.
53
+ """
54
+
55
+ INCONVENIENT = "inconvenient"
56
+ """A day of leave, a rebooked dinner. Annoying, survivable."""
57
+
58
+ SERIOUS = "serious"
59
+ """A missed day of a paid conference, a non-refundable booking."""
60
+
61
+ UNMISSABLE = "unmissable"
62
+ """Your own talk, a wedding, the last connection to a cruise."""
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class HardConstraint:
67
+ """A requirement that is checked, never priced — plus the stakes of missing it.
68
+
69
+ `buffer_hours` is the part people forget: arriving 40 minutes before a conference
70
+ opens satisfies the letter of the constraint and fails the point of it. The
71
+ default of 12 hours means "the evening before".
72
+
73
+ `criticality` (or an explicit `violation_cost`) is what replaces the old
74
+ business/holiday distinction: cushion, risk appetite and the rejection of clever
75
+ itineraries all follow from how much missing this would cost.
76
+ """
77
+
78
+ kind: ConstraintKind
79
+ when: datetime
80
+ place: str | None = None
81
+ """None means "wherever the plan ends" (HOME_BY) or "anywhere" (DEPART_AFTER)."""
82
+ buffer_hours: float = 12.0
83
+ label: str = ""
84
+ """Human name — "IETF 129 opening session" beats "constraint 2" in a report."""
85
+ criticality: Criticality = Criticality.SERIOUS
86
+ violation_cost: float | None = None
87
+ """Overrides the criticality price when the real number is known."""
88
+
89
+ def cost_of_missing(self, prices: object) -> float:
90
+ """What missing this costs, in the normalization currency.
91
+
92
+ `prices` is a `ConstraintPrefs`; it is typed loosely to keep this module free
93
+ of a preferences import (constraints are facts, preferences are valuations).
94
+ """
95
+ if self.violation_cost is not None:
96
+ return self.violation_cost
97
+ return float(getattr(prices, self.criticality.value))
98
+
99
+ def stake_weight(self, prices: object) -> float:
100
+ """0..1 — how close to unmissable this is, for buffer inflation (§8.9)."""
101
+ top = float(getattr(prices, Criticality.UNMISSABLE.value))
102
+ if top <= 0:
103
+ return 0.0
104
+ return min(1.0, self.cost_of_missing(prices) / top)
105
+
106
+ @property
107
+ def effective_deadline(self) -> datetime:
108
+ if self.kind is ConstraintKind.DEPART_AFTER:
109
+ return self.when + timedelta(hours=self.buffer_hours)
110
+ return self.when - timedelta(hours=self.buffer_hours)
111
+
112
+ def describe(self) -> str:
113
+ name = self.label or self.place or ""
114
+ where = f" at {self.place}" if self.place else ""
115
+ buffer = f" (+{self.buffer_hours:g}h buffer)" if self.buffer_hours else ""
116
+ return f"{self.kind}{where} {self.when:%Y-%m-%d %H:%M}{buffer}" + (
117
+ f" — {name}" if self.label else ""
118
+ )
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class ConstraintViolation:
123
+ constraint: HardConstraint
124
+ detail: str
125
+
126
+ def __str__(self) -> str:
127
+ return f"{self.constraint.describe()}: {self.detail}"
128
+
129
+
130
+ def _arrival_at(plan: TripPlan, place: str) -> datetime | None:
131
+ """When the plan first puts the traveller on the ground at `place`."""
132
+ for segment in plan.segments:
133
+ if segment.destination == place:
134
+ return segment.arrive
135
+ return None
136
+
137
+
138
+ def check(plan: TripPlan, constraints: list[HardConstraint]) -> list[ConstraintViolation]:
139
+ """Every way this plan fails a hard requirement.
140
+
141
+ All violations are returned rather than the first, because "this plan misses the
142
+ conference *and* gets you home a day late" is more useful than either half.
143
+ """
144
+ violations: list[ConstraintViolation] = []
145
+
146
+ for constraint in constraints:
147
+ deadline = constraint.effective_deadline
148
+
149
+ if constraint.kind is ConstraintKind.ARRIVE_BY:
150
+ if constraint.place is None:
151
+ arrival = plan.arrive
152
+ else:
153
+ arrival = _arrival_at(plan, constraint.place)
154
+ if arrival is None:
155
+ violations.append(
156
+ ConstraintViolation(
157
+ constraint, f"the plan never reaches {constraint.place}"
158
+ )
159
+ )
160
+ continue
161
+ if arrival > deadline:
162
+ late = (arrival - deadline).total_seconds() / 3600
163
+ violations.append(
164
+ ConstraintViolation(
165
+ constraint,
166
+ f"arrives {arrival:%Y-%m-%d %H:%M}, {late:.1f}h past the "
167
+ f"{constraint.buffer_hours:g}h buffer",
168
+ )
169
+ )
170
+
171
+ elif constraint.kind is ConstraintKind.DEPART_AFTER:
172
+ if plan.depart < deadline:
173
+ early = (deadline - plan.depart).total_seconds() / 3600
174
+ violations.append(
175
+ ConstraintViolation(
176
+ constraint,
177
+ f"departs {plan.depart:%Y-%m-%d %H:%M}, {early:.1f}h too early",
178
+ )
179
+ )
180
+
181
+ elif constraint.kind is ConstraintKind.HOME_BY and plan.arrive > deadline:
182
+ late = (plan.arrive - deadline).total_seconds() / 3600
183
+ violations.append(
184
+ ConstraintViolation(
185
+ constraint,
186
+ f"home {plan.arrive:%Y-%m-%d %H:%M}, {late:.1f}h late",
187
+ )
188
+ )
189
+
190
+ return violations
191
+
192
+
193
+ def slack_before(plan: TripPlan, constraint: HardConstraint) -> float | None:
194
+ """Hours of room a plan has against one constraint.
195
+
196
+ This is what makes discovered stopovers possible on a work trip: a conference
197
+ that opens Thursday morning leaves days of slack on the way out, and slack is
198
+ exactly what a stopover spends. Returns None when the constraint does not apply
199
+ to this plan.
200
+ """
201
+ deadline = constraint.effective_deadline
202
+ if constraint.kind is ConstraintKind.ARRIVE_BY:
203
+ arrival = (
204
+ plan.arrive if constraint.place is None else _arrival_at(plan, constraint.place)
205
+ )
206
+ if arrival is None:
207
+ return None
208
+ return (deadline - arrival).total_seconds() / 3600
209
+ if constraint.kind is ConstraintKind.DEPART_AFTER:
210
+ return (plan.depart - deadline).total_seconds() / 3600
211
+ if constraint.kind is ConstraintKind.HOME_BY:
212
+ return (deadline - plan.arrive).total_seconds() / 3600
213
+ return None
layover/cost/curve.py ADDED
@@ -0,0 +1,145 @@
1
+ """One curve, used by everything that has a min, an ideal and a max (§8.4).
2
+
3
+ Departure, arrival and length are the same shape of preference. You state the
4
+ earliest that is possible at all, the value you actually want, and the latest — and
5
+ everything between is priced by how far it is from the ideal, in units of a tolerance
6
+ breadth:
7
+
8
+ cost = scale × ((value − ideal) / tolerance)²
9
+
10
+ Quadratic rather than linear because that is how the preference behaves: the first
11
+ step off target barely registers, the last one before the hard edge is expensive. The
12
+ tolerance defaults, per side, to the distance from the ideal to that bound — so the
13
+ edges cost exactly `scale`, and where the ideal sits inside the window *is* the
14
+ statement about which side is tighter. No asymmetry syntax required.
15
+
16
+ Everything here is in one unit. Callers convert first: days or hours for moments,
17
+ nights for lengths. That is the whole reason this module knows nothing about dates.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Bounds:
27
+ """A window's three values, after the missing ones have been filled in."""
28
+
29
+ low: float
30
+ ideal: float
31
+ high: float
32
+ inferred: tuple[str, ...] = ()
33
+ """Which of the three were not stated — the report says so rather than pretending."""
34
+
35
+ def contains(self, value: float) -> bool:
36
+ return self.low <= value <= self.high
37
+
38
+
39
+ def resolve(
40
+ low: float | None,
41
+ ideal: float | None,
42
+ high: float | None,
43
+ *,
44
+ spread: float | None = None,
45
+ ) -> Bounds:
46
+ """Fill in whichever of min/ideal/max were left out.
47
+
48
+ Two of the three is usually what someone actually knows — "no earlier than the
49
+ 22nd, ideally the 24th", or "between five and nine nights" — and requiring the
50
+ third would only invite a made-up number. The rules:
51
+
52
+ * **min and max, no ideal.** The middle. Nothing was said to prefer either end.
53
+ * **one bound and an ideal.** Mirror the stated side. "Ideally Tuesday, no later
54
+ than Friday" says three days late is the limit, and says nothing about early;
55
+ treating early as unbounded would let the search wander off, and treating it as
56
+ forbidden would rule out Monday for no reason. Symmetry is the reading that
57
+ invents least.
58
+ * **an ideal alone.** A `spread` if the caller has one (`plus_minus_days`), else a
59
+ window of exactly one value — which is a legitimate thing to want.
60
+
61
+ Nothing at all is an error; a window has to be about something.
62
+ """
63
+ inferred: list[str] = []
64
+ if ideal is None:
65
+ if low is not None and high is not None:
66
+ ideal = (low + high) / 2
67
+ inferred.append("ideal")
68
+ elif low is not None or high is not None:
69
+ ideal = low if low is not None else high
70
+ inferred.append("ideal")
71
+ else:
72
+ raise ValueError("a window needs at least one of min, ideal or max")
73
+
74
+ if low is None and high is None and spread is not None:
75
+ low, high = ideal - spread, ideal + spread
76
+ if low is None:
77
+ low = ideal - (high - ideal) if high is not None else ideal
78
+ inferred.append("min")
79
+ if high is None:
80
+ high = ideal + (ideal - low)
81
+ inferred.append("max")
82
+
83
+ if low > high:
84
+ raise ValueError(f"window is empty: min {low} is after max {high}")
85
+ if not low <= ideal <= high:
86
+ raise ValueError(f"ideal {ideal} lies outside min {low}..max {high}")
87
+ return Bounds(low, ideal, high, tuple(inferred))
88
+
89
+
90
+ def tolerance_for(bounds: Bounds, *, earlier: bool, stated: float | None = None) -> float:
91
+ """The breadth of the curve on one side, in the caller's unit."""
92
+ if stated is not None:
93
+ return stated
94
+ return (bounds.ideal - bounds.low) if earlier else (bounds.high - bounds.ideal)
95
+
96
+
97
+ def cost(
98
+ value: float,
99
+ bounds: Bounds,
100
+ *,
101
+ scale: float,
102
+ tolerance: float | None = None,
103
+ offset: float = 0.0,
104
+ ) -> float | None:
105
+ """What this value costs. None means outside the window — not merely expensive.
106
+
107
+ Outside the bounds there is no window at all, which every caller must read as
108
+ "this does not exist" rather than "this is dear". Hard edges stay hard (§8.1).
109
+
110
+ `offset` moves the whole curve vertically, which is how a stop that is *worth
111
+ having* is expressed: a fortnight in Punta Cana at −800 rises through zero and
112
+ eventually goes positive, because the square swamps any offset in the end. That
113
+ is what bounds a desirable stay without needing a hard maximum — and it is only
114
+ meaningful for lengths. Moments are relative (you must depart at some point, so
115
+ only differences matter); lengths are absolute, since not stopping at all is a
116
+ real alternative (§8.3).
117
+ """
118
+ if not bounds.contains(value):
119
+ return None
120
+ delta = value - bounds.ideal
121
+ if not delta:
122
+ return offset
123
+ breadth = tolerance_for(bounds, earlier=delta < 0, stated=tolerance)
124
+ if not breadth:
125
+ # The bound and the ideal coincide on this side, so anything off-target is
126
+ # already at the limit of what was allowed.
127
+ return offset + scale
128
+ return offset + scale * (delta / breadth) ** 2
129
+
130
+
131
+ def breakeven(
132
+ bounds: Bounds, *, scale: float, tolerance: float | None = None, offset: float = 0.0
133
+ ) -> tuple[float, float] | None:
134
+ """Where an offset curve crosses zero — the range over which a stop pays for itself.
135
+
136
+ The answerable form of the question §10 asks: not "do you like Santorini?" but
137
+ "three to eleven nights pays for itself; past eleven it stops". None when the curve
138
+ never crosses, which means either the stop is never worth it or always is.
139
+ """
140
+ if offset >= 0:
141
+ return None
142
+ low = tolerance_for(bounds, earlier=True, stated=tolerance)
143
+ high = tolerance_for(bounds, earlier=False, stated=tolerance)
144
+ reach = (-offset / scale) ** 0.5 if scale > 0 else float("inf")
145
+ return bounds.ideal - low * reach, bounds.ideal + high * reach
layover/cost/delay.py ADDED
@@ -0,0 +1,173 @@
1
+ """Delay priors and punctuality blending (DESIGN §8.5).
2
+
3
+ Delay is systematic, not noise: the first rotation leaves on time because the
4
+ aircraft slept there, slip accumulates through the day, and evening departures are
5
+ worst with no recovery flight left. A coarse prior by local departure hour is
6
+ enough to make an 07:20 departure beat a 19:05 one at equal fare.
7
+
8
+ For shortlisted flights the prior is *refined* by observed punctuality, never
9
+ replaced by it: four recent flights should nudge an estimate, sixty should decide
10
+ it. That is Bayesian shrinkage with the prior worth k pseudo-observations.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import tomllib
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class DelayEstimate:
22
+ """Expected slip and the tail probability that decides connections."""
23
+
24
+ mean_min: float
25
+ p_over_60: float
26
+ cancelled_pct: float = 1.5
27
+ samples: int = 0
28
+ source: str = "prior"
29
+
30
+ @property
31
+ def is_observed(self) -> bool:
32
+ return self.samples > 0
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PunctualityObservation:
37
+ """What a punctuality oracle reported for one flight (ORACLES §8)."""
38
+
39
+ mean_delay_min: float
40
+ p_over_60: float
41
+ samples: int
42
+ cancelled_pct: float = 0.0
43
+ window_days: int = 90
44
+ same_season: bool = True
45
+ source: str = "unknown"
46
+
47
+
48
+ class DelayTable:
49
+ """Hour-bucket prior, loaded from `reference/delay_profiles.toml`."""
50
+
51
+ def __init__(
52
+ self,
53
+ buckets: dict[tuple[int, int], DelayEstimate],
54
+ *,
55
+ long_haul_mean_multiplier: float = 1.1,
56
+ long_haul_p_multiplier: float = 0.8,
57
+ cancellation_prior_pct: float = 1.5,
58
+ as_of: str = "unknown",
59
+ source: str = "unknown",
60
+ ) -> None:
61
+ if not buckets:
62
+ raise ValueError("delay table needs at least one hour bucket")
63
+ self._buckets = buckets
64
+ self.long_haul_mean_multiplier = long_haul_mean_multiplier
65
+ self.long_haul_p_multiplier = long_haul_p_multiplier
66
+ self.cancellation_prior_pct = cancellation_prior_pct
67
+ self.as_of = as_of
68
+ self.source = source
69
+
70
+ @classmethod
71
+ def from_toml(cls, path: Path) -> DelayTable:
72
+ raw = tomllib.loads(path.read_text())
73
+ buckets: dict[tuple[int, int], DelayEstimate] = {}
74
+ cancel = float((raw.get("cancellation") or {}).get("prior_pct", 1.5))
75
+ for span, body in (raw.get("by_local_departure_hour") or {}).items():
76
+ start, _, end = span.partition("-")
77
+ buckets[(int(start), int(end))] = DelayEstimate(
78
+ mean_min=float(body["mean_min"]),
79
+ p_over_60=float(body["p_over_60"]),
80
+ cancelled_pct=cancel,
81
+ )
82
+ long_haul = raw.get("long_haul") or {}
83
+ return cls(
84
+ buckets,
85
+ long_haul_mean_multiplier=float(long_haul.get("mean_min_multiplier", 1.1)),
86
+ long_haul_p_multiplier=float(long_haul.get("p_over_60_multiplier", 0.8)),
87
+ cancellation_prior_pct=cancel,
88
+ as_of=str(raw.get("as_of", "unknown")),
89
+ source=str(raw.get("source", "unknown")),
90
+ )
91
+
92
+ @classmethod
93
+ def fallback(cls) -> DelayTable:
94
+ """Built-in prior, so the cost model works before reference data is wired."""
95
+ return cls(
96
+ {
97
+ (0, 5): DelayEstimate(18, 0.10),
98
+ (5, 9): DelayEstimate(8, 0.04),
99
+ (9, 13): DelayEstimate(14, 0.07),
100
+ (13, 18): DelayEstimate(20, 0.11),
101
+ (18, 24): DelayEstimate(28, 0.16),
102
+ },
103
+ as_of="built-in",
104
+ source="built-in fallback prior",
105
+ )
106
+
107
+ def prior_for_hour(self, hour: int, *, long_haul: bool = False) -> DelayEstimate:
108
+ hour %= 24
109
+ for (start, end), bucket in sorted(self._buckets.items()):
110
+ if start <= hour < end:
111
+ estimate = bucket
112
+ break
113
+ else: # pragma: no cover — buckets are expected to cover the clock
114
+ estimate = max(self._buckets.items())[1]
115
+ if not long_haul:
116
+ return estimate
117
+ return DelayEstimate(
118
+ mean_min=estimate.mean_min * self.long_haul_mean_multiplier,
119
+ p_over_60=min(1.0, estimate.p_over_60 * self.long_haul_p_multiplier),
120
+ cancelled_pct=estimate.cancelled_pct,
121
+ source="prior (long-haul adjusted)",
122
+ )
123
+
124
+
125
+ def blend(
126
+ prior: DelayEstimate,
127
+ observed: PunctualityObservation | None,
128
+ *,
129
+ pseudo_observations: int = 10,
130
+ ) -> DelayEstimate:
131
+ """Shrink observed punctuality toward the prior.
132
+
133
+ With k=10, four samples move the estimate ~29% of the way to what was
134
+ observed and sixty samples move it ~86%. Small samples nudge; large samples
135
+ decide. An out-of-season window is discounted by halving its effective weight,
136
+ because a January sample says little about August.
137
+ """
138
+ if observed is None or observed.samples <= 0:
139
+ return prior
140
+
141
+ k = max(1, pseudo_observations)
142
+ weight = observed.samples * (1.0 if observed.same_season else 0.5)
143
+ total = k + weight
144
+
145
+ return DelayEstimate(
146
+ mean_min=(k * prior.mean_min + weight * observed.mean_delay_min) / total,
147
+ p_over_60=(k * prior.p_over_60 + weight * observed.p_over_60) / total,
148
+ cancelled_pct=(k * prior.cancelled_pct + weight * observed.cancelled_pct) / total,
149
+ samples=observed.samples,
150
+ source=f"blended: {observed.source} ({observed.samples} samples, k={k})",
151
+ )
152
+
153
+
154
+ def p_miss(estimate: DelayEstimate, cushion_minutes: float) -> float:
155
+ """Probability an unprotected connection is missed, given its total cushion.
156
+
157
+ `cushion_minutes` is *all* the absorbing time the connection has: the slack
158
+ beyond the required buffer plus the delay allowance already built into that
159
+ buffer (§8.9). Callers must not pass bare slack — the buffer already contains
160
+ an allowance, and double-counting it the other way would make every connection
161
+ look doomed.
162
+
163
+ `p_over_60` anchors the curve: P(delay > t) = p_over_60 ** (t / 60). Crude on
164
+ purpose — a delay distribution is not knowable from two numbers — but it has
165
+ the properties that matter: monotonically decreasing in cushion, never zero,
166
+ and exactly the observed hourly tail at 60 minutes.
167
+ """
168
+ if cushion_minutes <= 0:
169
+ # No absorbing time at all: the connection needs an on-time arrival, which
170
+ # even good flights cannot promise.
171
+ return 1.0
172
+ anchor = min(max(estimate.p_over_60, 1e-4), 0.99)
173
+ return float(min(1.0, anchor ** (cushion_minutes / 60.0)))