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,333 @@
1
+ """Preference profile: the money-denominated valuations (DESIGN §8).
2
+
3
+ Every field here is a **price** — per hour, per night, per event — never a weight.
4
+ That is what keeps the objective a single comparable scalar and lets a report explain
5
+ a ranking in euros.
6
+
7
+ These are the *soft* half. Hard requirements ("be in Bogotá before the conference
8
+ opens") live in `cost/constraints.py`, are checked rather than priced, and carry the
9
+ stakes of missing them. There is deliberately **no trip type**: "business" turned out
10
+ to be a bundle of things that either belong to a constraint (a deadline, and how much
11
+ cushion its stakes deserve) or are plain numbers that may differ per leg.
12
+
13
+ Three families, kept apart because they answer different questions:
14
+
15
+ 1. **Time** (`[time]`) — what an hour is worth, times how much of the hour you get
16
+ back. The factors here are *productivity* only: in-flight time is discounted
17
+ because you can sleep or work, lounge time because you can work.
18
+ 2. **Discomfort** (`[discomfort]`) — what unpleasantness is worth, as fixed amounts
19
+ scaled by one `scale` knob. Deliberately **not** multiplied by the wage rate: how
20
+ much you mind a broken night or dragging bags across a city does not depend on what
21
+ your hour is worth. This is the comfort axis, and it is separate on purpose.
22
+ 3. **Risk** — no longer configured as premiums. It is computed: the probability of
23
+ missing a connection times what missing it costs, including the stakes of any hard
24
+ constraint it would break.
25
+
26
+ Loading order (later wins, per key):
27
+ 1. repo `config/preferences.toml` — shipped defaults
28
+ 2. `~/.config/layover/preferences.toml` — the owner's values
29
+ 3. per-intent or per-leg overrides — plain nested dicts
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import tomllib
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from pydantic import BaseModel, ConfigDict, Field
39
+
40
+
41
+ class Strict(BaseModel):
42
+ """Base for every preference model: unknown keys are errors, not noise.
43
+
44
+ Pydantic ignores extra fields by default, which meant a mistyped or
45
+ wrongly-nested config key was silently dropped and the shipped default used
46
+ instead — a wrong ranking that looks right. Forbidding extras turns that into a
47
+ startup error naming the key.
48
+ """
49
+
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+
53
+ class TimePrefs(Strict):
54
+ """The price of time, and how much of each hour you actually get back."""
55
+
56
+ wage_rate_per_hour: float = 25.0
57
+ in_flight_factor: float = 0.6
58
+ """You can sleep, read, work. Productivity, not comfort."""
59
+ layover_factor: float = 1.0
60
+ """The reference unit: an hour you get nothing back from."""
61
+ ground_transfer_factor: float = 1.0
62
+ """A train between airports is about as usable as a gate area."""
63
+
64
+
65
+ class DiscomfortPrefs(Strict):
66
+ """What unpleasantness costs, independent of what an hour is worth.
67
+
68
+ Fixed amounts rather than time multipliers, because most of these are not
69
+ proportional to duration: a broken night is a broken night whether the flight is
70
+ two hours or twelve, and the stress of a 45-minute connection is not 45 minutes of
71
+ anything. Each is multiplied by `scale` — the single comfort knob: 0.5 if airports
72
+ genuinely don't bother you, 2.0 for an easy trip at almost any price.
73
+ """
74
+
75
+ scale: float = 1.0
76
+
77
+ red_eye_per_flight: float = 25.0
78
+ """Departing 23:00–05:00: a night you don't get back."""
79
+ red_eye_from_hour: int = 23
80
+ red_eye_to_hour: int = 5
81
+
82
+ tight_connection: float = 12.0
83
+ """Running for a gate. Fixed, not per minute."""
84
+ tight_connection_minutes: int = 90
85
+
86
+ night_in_terminal: float = 90.0
87
+ """Sleeping in an airport, per night."""
88
+
89
+ bag_recheck: float = 10.0
90
+ """Claim, queue, re-drop."""
91
+
92
+ self_transfer: float = 25.0
93
+ """Managing your own connection: watching the clock, nobody helping if it slips."""
94
+ separate_ticket: float = 30.0
95
+ """Two contracts instead of one — rebooking is yours to do. This is the *hassle*
96
+ of it; the money risk is computed from probabilities, not set here."""
97
+ third_party_guarantee: float = 10.0
98
+ """Aggregator-guaranteed self-transfer: covered, but you handle the claim."""
99
+ airport_change: float = 45.0
100
+ """Crossing a city with luggage between two flights."""
101
+ border_entry_exit: float = 40.0
102
+ """Immigration, customs and re-screening in the middle of a journey."""
103
+ lcc_bag_gauge: float = 10.0
104
+ """The gate-side sizer lottery."""
105
+
106
+ def cost(self, amount: float) -> float:
107
+ """Apply the comfort scale to one discomfort amount."""
108
+ return amount * self.scale
109
+
110
+
111
+ class OvernightPrefs(Strict):
112
+ """Where an overnight gap is spent: the terminal's misery is discomfort, the hotel
113
+ is real money."""
114
+
115
+ airport_hotel_cost: float = 90.0
116
+ hotel_transfer_hours: float = 1.5
117
+ window_from_hour: int = 22
118
+ window_to_hour: int = 7
119
+ min_night_hours: float = 5.0
120
+ """Shorter than this is a late wait, not a lost night."""
121
+
122
+
123
+ class LoungePrefs(Strict):
124
+ wait_factor: float = 0.35
125
+ """A lounge gives most of the hour back — productivity, so it belongs with time."""
126
+ min_wait_to_use_h: float = 1.5
127
+ overage_price: float = 30.0
128
+ trips_per_year: int = 8
129
+ use: str = "if_worth_it"
130
+ """if_worth_it | never. There is no "always": a lounge is taken when it pays for
131
+ itself, and its absence makes a plan slightly worse — never infeasible."""
132
+
133
+
134
+ class StopoverPrefs(Strict):
135
+ """What a stop is worth, as one signed number per place (§8.3).
136
+
137
+ Net, always: hotel minus whatever being there is worth. A hotel rate is guessable
138
+ and what a place is worth to *you* is not, and only the difference ever ranks
139
+ anything — so the model asks for the difference and nothing else.
140
+ """
141
+
142
+ candidate_min_hours: float = 10.0
143
+ """Gaps at least this long are offered as stopovers (DESIGN §8.3). Below it the
144
+ question is noise: nobody takes a hotel for a six-hour connection."""
145
+ offer: bool = True
146
+ """Whether unexpected long gaps are offered at all. Slack decides whether there is
147
+ anything to offer; this only silences the question for a run."""
148
+ net: dict[str, float] = Field(default_factory=dict)
149
+ """Standing wishlist, place -> net cost of a stay there. **Negative means wanted.**
150
+
151
+ A routing through one of these is a wanted stop without asking — the owner
152
+ answered in advance. Zero is the honest default for a place never mentioned: a
153
+ wash, neither hunted for nor penalized."""
154
+
155
+
156
+ class FlexibilityPrefs(Strict):
157
+ date_cost_at_tolerance: float = 60.0
158
+ """What the edge of a date window costs when the intent does not price it itself.
159
+
160
+ The scale of the quadratic in §8.4: `cost = this × ((when − ideal) / tolerance)²`,
161
+ with the tolerance defaulting to the distance from the ideal to each bound. A trip
162
+ that cares unusually much (or little) about its dates overrides it per window;
163
+ this is the standing answer to "what is a day of slippage worth to me"."""
164
+ length_cost_at_tolerance: float = 40.0
165
+ """The same, for windows counted in nights: trip length and stopover length.
166
+
167
+ Its own number because "a day later than I wanted to leave" and "a night longer
168
+ than I wanted to stay" are not the same regret — the second one you are at least
169
+ somewhere, which is why this is the smaller of the two."""
170
+ date_deviation_cost_per_day: float = 12.0
171
+ """Only for windows written with the older flat per-day sugar."""
172
+ duration_deviation_cost_per_night: float = 10.0
173
+ """Symmetric fallback, used when neither direction below is set."""
174
+ extra_day_away_cost: float | None = None
175
+ """Each night longer than preferred: leave days, a second hotel, the dog.
176
+
177
+ This is the number a discovered stopover has to beat — leaving three days early
178
+ for Punta Cana costs three days away from home, and the value of being there must
179
+ cover them."""
180
+ missing_day_cost: float | None = None
181
+ """Each night shorter than preferred: less trip than you wanted."""
182
+ time_of_day: dict[str, dict[str, float]] = Field(default_factory=dict)
183
+
184
+ def duration_cost(self, nights: int, preferred: int) -> float:
185
+ """Price the gap between a trip's length and the length that was wanted.
186
+
187
+ Asymmetric on purpose: an extra day away and a day short of the trip you
188
+ planned are different kinds of loss.
189
+ """
190
+ if nights == preferred:
191
+ return 0.0
192
+ if nights > preferred:
193
+ rate = (
194
+ self.extra_day_away_cost
195
+ if self.extra_day_away_cost is not None
196
+ else self.duration_deviation_cost_per_night
197
+ )
198
+ return (nights - preferred) * rate
199
+ rate = (
200
+ self.missing_day_cost
201
+ if self.missing_day_cost is not None
202
+ else self.duration_deviation_cost_per_night
203
+ )
204
+ return (preferred - nights) * rate
205
+
206
+
207
+ class DelayPrefs(Strict):
208
+ misconnect_replacement_cost: float = 180.0
209
+ """Fallback price of a replacement ticket when the onward leg's real price is not
210
+ known. This is the *money* consequence of a miss."""
211
+ prior_pseudo_observations: int = 10
212
+ """Weight of the static prior when blending observed punctuality."""
213
+ recovery_hours: float = 6.0
214
+ """How much later you realistically arrive after missing a connection."""
215
+ recovery_hours_overnight: float = 20.0
216
+ """...and when the missed flight was the last departure of the day."""
217
+
218
+
219
+ class ConstraintPrefs(Strict):
220
+ """What missing a hard requirement costs (DESIGN §8.1).
221
+
222
+ This is what "definitely arrive by 20:00" actually means. A schedule check alone
223
+ would pass any plan that *plans* to arrive at 14:00, however likely it is to slip
224
+ to tomorrow. Stakes make risky plans lose on cost, and make the composer demand
225
+ more cushion — which is the job the old business/holiday switch was doing badly.
226
+ """
227
+
228
+ inconvenient: float = 150.0
229
+ serious: float = 800.0
230
+ unmissable: float = 3000.0
231
+ stake_buffer_max_multiplier: float = 1.5
232
+ """Required buffers inflate up to this much as stakes rise (§8.9). At
233
+ `unmissable` this reproduces the old business-mode 1.5x — for the right reason."""
234
+
235
+ def cost_of(self, criticality: str) -> float:
236
+ value = getattr(self, criticality, None)
237
+ if value is None:
238
+ raise KeyError(f"unknown criticality {criticality!r}")
239
+ return float(value)
240
+
241
+
242
+ class FeasibilityPrefs(Strict):
243
+ """Physical minimums: whether a connection is *makeable*.
244
+
245
+ Whether it is *wise* is the objective's judgement, via expected cost — which is
246
+ why the old `allow_self_transfer` / `buffer_multiplier` knobs are gone.
247
+ """
248
+
249
+ absolute_floor_h: float = 2.0
250
+ self_transfer_same_airport_h: float = 3.0
251
+ self_transfer_bag_recheck_h: float = 4.0
252
+ self_transfer_entry_required_h: float = 5.0
253
+ airport_change_h: float = 5.0
254
+ require_protected: bool = False
255
+ """For a run where only single-ticket itineraries are acceptable at any price. A
256
+ blunt instrument, rarely needed now that risk is priced against stakes."""
257
+ protected_connection: str = "carrier_mct"
258
+ carrier_mct_default_minutes: float = 45.0
259
+
260
+
261
+ class GroundPrefs(Strict):
262
+ default_metro_transfer_cost: float = 25.0
263
+ default_metro_transfer_hours: float = 1.25
264
+
265
+
266
+ class VerificationPrefs(Strict):
267
+ shortlist_size: int = 5
268
+ changed_threshold_fraction: float = 0.01
269
+ changed_threshold_absolute: float = 5.0
270
+
271
+
272
+ class PreferenceProfile(Strict):
273
+ normalize_currency: str = "EUR"
274
+ label: str | None = None
275
+ """Optional name for a saved set of overrides, for the report only. It carries no
276
+ meaning in the model — there is no trip type."""
277
+ time: TimePrefs = Field(default_factory=TimePrefs)
278
+ discomfort: DiscomfortPrefs = Field(default_factory=DiscomfortPrefs)
279
+ overnight: OvernightPrefs = Field(default_factory=OvernightPrefs)
280
+ lounge: LoungePrefs = Field(default_factory=LoungePrefs)
281
+ stopover: StopoverPrefs = Field(default_factory=StopoverPrefs)
282
+ flexibility: FlexibilityPrefs = Field(default_factory=FlexibilityPrefs)
283
+ delay: DelayPrefs = Field(default_factory=DelayPrefs)
284
+ constraints: ConstraintPrefs = Field(default_factory=ConstraintPrefs)
285
+ feasibility: FeasibilityPrefs = Field(default_factory=FeasibilityPrefs)
286
+ ground: GroundPrefs = Field(default_factory=GroundPrefs)
287
+ verification: VerificationPrefs = Field(default_factory=VerificationPrefs)
288
+
289
+ def with_overrides(self, overrides: dict[str, Any]) -> PreferenceProfile:
290
+ """Return a copy with a nested dict merged in (per key, not per section).
291
+
292
+ This is also how a per-leg difference is expressed: a leg is not a "mode", it
293
+ is these numbers with a few of them changed.
294
+ """
295
+ return PreferenceProfile.model_validate(_deep_merge(self.model_dump(), overrides))
296
+
297
+
298
+ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
299
+ out = dict(base)
300
+ for key, value in overlay.items():
301
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
302
+ out[key] = _deep_merge(out[key], value)
303
+ else:
304
+ out[key] = value
305
+ return out
306
+
307
+
308
+ def load_preferences(
309
+ paths: list[Path],
310
+ *,
311
+ overrides: dict[str, Any] | None = None,
312
+ ) -> PreferenceProfile:
313
+ """Compose a profile from config files, then overrides.
314
+
315
+ Missing files are skipped: the shipped defaults are a complete configuration, so a
316
+ first run works before anything is customized.
317
+ """
318
+ merged: dict[str, Any] = {}
319
+ for path in paths:
320
+ if path.is_file():
321
+ merged = _deep_merge(merged, tomllib.loads(path.read_text()))
322
+ if overrides:
323
+ merged = _deep_merge(merged, overrides)
324
+ return PreferenceProfile.model_validate(merged)
325
+
326
+
327
+ def default_config_paths(repo_config: Path | None = None) -> list[Path]:
328
+ """Shipped defaults first, then the owner's overrides."""
329
+ paths: list[Path] = []
330
+ if repo_config is not None:
331
+ paths.append(repo_config / "preferences.toml")
332
+ paths.append(Path.home() / ".config" / "layover" / "preferences.toml")
333
+ return paths
@@ -0,0 +1,125 @@
1
+ """Travellers and parties (DESIGN §8.6, §13.9).
2
+
3
+ Identity and entitlements only, loaded from its own file — never the repo, never
4
+ mixed with valuations. No document numbers: nationality, expiry and authorization
5
+ validity are everything the cost model needs, and nothing here books anything.
6
+
7
+ A party is 1–2 adults on one itinerary. The combining rules matter more than they
8
+ look: time takes the party's *maximum* (a couple clears immigration together, at
9
+ the slower queue), money and applications are *per person*, and one traveller who
10
+ cannot be admitted makes the routing infeasible for everyone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import tomllib
16
+ from datetime import date
17
+ from pathlib import Path
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+
22
+ class Passport(BaseModel):
23
+ nationality: str = Field(min_length=2, max_length=2)
24
+ expires: date | None = None
25
+
26
+ def valid_on(self, day: date) -> bool:
27
+ return self.expires is None or self.expires > day
28
+
29
+
30
+ class Authorization(BaseModel):
31
+ """A travel authorization (ESTA, UK ETA, eTA, ETIAS, or a full visa).
32
+
33
+ Bound to the passport it was issued against, because that is how it works —
34
+ and because a spouse holding IT+BR can only ever hang a US authorization off
35
+ the IT passport.
36
+ """
37
+
38
+ kind: str
39
+ passport: str = Field(min_length=2, max_length=2)
40
+ expires: date | None = None
41
+
42
+ def covers(self, nationality: str, day: date) -> bool:
43
+ return self.passport == nationality and (self.expires is None or self.expires > day)
44
+
45
+
46
+ class LoungeMembership(BaseModel):
47
+ program: str = "none"
48
+ annual_quota: int = 0
49
+ quota_used: int = 0
50
+ year_resets_on: date | None = None
51
+
52
+ @property
53
+ def quota_remaining(self) -> int:
54
+ return max(0, self.annual_quota - self.quota_used)
55
+
56
+ @property
57
+ def has_program(self) -> bool:
58
+ return self.program != "none" and self.annual_quota > 0
59
+
60
+
61
+ class Traveler(BaseModel):
62
+ id: str
63
+ passports: list[Passport] = Field(default_factory=list)
64
+ held_authorizations: list[Authorization] = Field(default_factory=list)
65
+ lounge: LoungeMembership = Field(default_factory=LoungeMembership)
66
+
67
+ @property
68
+ def nationalities(self) -> list[str]:
69
+ return [p.nationality for p in self.passports]
70
+
71
+ def passport_valid_on(self, nationality: str, day: date) -> bool:
72
+ return any(p.nationality == nationality and p.valid_on(day) for p in self.passports)
73
+
74
+ def holds(self, kind: str, nationality: str, day: date) -> bool:
75
+ return any(
76
+ a.kind == kind and a.covers(nationality, day) for a in self.held_authorizations
77
+ )
78
+
79
+
80
+ class Party(BaseModel):
81
+ """The travellers on one search."""
82
+
83
+ travelers: list[Traveler] = Field(min_length=1)
84
+
85
+ @property
86
+ def size(self) -> int:
87
+ return len(self.travelers)
88
+
89
+ @property
90
+ def ids(self) -> list[str]:
91
+ return [t.id for t in self.travelers]
92
+
93
+ def get(self, traveler_id: str) -> Traveler:
94
+ for t in self.travelers:
95
+ if t.id == traveler_id:
96
+ return t
97
+ raise KeyError(f"no traveller {traveler_id!r} in party {self.ids}")
98
+
99
+
100
+ def load_travelers(path: Path) -> dict[str, Traveler]:
101
+ """Load `travelers.toml`. Absent file = no travellers configured."""
102
+ if not path.is_file():
103
+ return {}
104
+ raw = tomllib.loads(path.read_text())
105
+ out: dict[str, Traveler] = {}
106
+ for traveler_id, body in (raw.get("traveler") or {}).items():
107
+ data = dict(body)
108
+ lounge = data.pop("lounge", None)
109
+ out[traveler_id] = Traveler.model_validate(
110
+ {
111
+ "id": traveler_id,
112
+ **data,
113
+ **({"lounge": lounge} if lounge else {}),
114
+ }
115
+ )
116
+ return out
117
+
118
+
119
+ def default_travelers_path() -> Path:
120
+ return Path.home() / ".config" / "layover" / "travelers.toml"
121
+
122
+
123
+ def solo(traveler_id: str = "owner", **kwargs) -> Party:
124
+ """A one-person party — the common case, and handy in tests."""
125
+ return Party(travelers=[Traveler(id=traveler_id, **kwargs)])