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,1207 @@
1
+ """Generalized cost: the ranking objective (DESIGN §8).
2
+
3
+ generalized_cost = money_total # fares, ancillaries, hotels, lounges
4
+ + time_cost # hours you don't get back, x wage rate
5
+ + discomfort # unpleasantness, at its own price
6
+ + stopover_net + flexibility + borders + ground
7
+ + expected_risk # P(miss) x consequence, incl. stakes
8
+
9
+ Three families, kept apart because they answer different questions: **time** (what
10
+ an hour is worth), **discomfort** (how much you mind things — not multiplied by the
11
+ wage rate), and **risk** (probabilities times consequences, including what missing a
12
+ hard constraint would cost). Nothing is a "premium" any more, and nothing is a mode.
13
+
14
+ One scalar band, in the normalization currency, with every term itemized so a
15
+ ranking can be explained in euros and argued with. `money_total` — what actually
16
+ leaves the account — is always carried alongside and never hidden.
17
+
18
+ Pricing is a pure function of (plan, context), so the same plan can be priced under
19
+ different valuations without mutating anything.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass, field, replace
26
+ from datetime import date, datetime, timedelta
27
+ from enum import StrEnum
28
+
29
+ from layover.cost import borders as borders_mod
30
+ from layover.cost import curve
31
+ from layover.cost import lounge as lounge_mod
32
+ from layover.cost.borders import PartyBorder, Regime, RegimeTable
33
+ from layover.cost.connection import FeasibilityVerdict, assess
34
+ from layover.cost.constraints import ConstraintKind, ConstraintViolation, HardConstraint
35
+ from layover.cost.constraints import check as check_constraints
36
+ from layover.cost.delay import DelayEstimate, DelayTable, PunctualityObservation, blend, p_miss
37
+ from layover.interact.assumptions import Assumption, AssumptionLog, Option
38
+ from layover.models import (
39
+ BaggageProfile,
40
+ Connection,
41
+ Protection,
42
+ RiskAnnotation,
43
+ RiskKind,
44
+ Segment,
45
+ TripPlan,
46
+ )
47
+ from layover.money import Band, band_sum
48
+ from layover.prefs.profile import PreferenceProfile
49
+ from layover.prefs.travelers import Party
50
+
51
+
52
+ class CostKind(StrEnum):
53
+ FARE = "fare"
54
+ DISCOMFORT = "discomfort"
55
+ ANCILLARY = "ancillary"
56
+ TRAVEL_TIME = "travel_time"
57
+ INVOLUNTARY_WAIT = "involuntary_wait"
58
+ STOPOVER = "stopover"
59
+ DATE_FLEXIBILITY = "date_flexibility"
60
+ DURATION_DEVIATION = "duration_deviation"
61
+ TIME_OF_DAY = "time_of_day"
62
+ EXPECTED_DELAY = "expected_delay"
63
+ BORDER = "border"
64
+ GROUND_TRANSFER = "ground_transfer"
65
+ LOUNGE = "lounge"
66
+ RISK_PREMIUM = "risk_premium"
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class CostLine:
71
+ kind: CostKind
72
+ amount: Band
73
+ note: str
74
+ traveler_id: str | None = None
75
+
76
+ def __str__(self) -> str:
77
+ who = f" [{self.traveler_id}]" if self.traveler_id else ""
78
+ return f"{self.kind:<18} {self.amount!s:>12} {self.note}{who}"
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class StopWindows:
83
+ """One planned stop, and the preferences attached to it.
84
+
85
+ Supplied by the intent, which is the only thing that knows what was asked for.
86
+ Callables rather than models so the cost model needs no import of the intent
87
+ layer — and `None` for a window nobody stated, which is the normal case.
88
+ """
89
+
90
+ place: str
91
+ arrive: Callable[[datetime], DateCost | None] | None = None
92
+ depart: Callable[[datetime], DateCost | None] | None = None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class StopMoments:
97
+ """Where a planned stop actually landed in a candidate itinerary."""
98
+
99
+ place: str
100
+ arrive: datetime | None = None
101
+ depart: datetime | None = None
102
+
103
+
104
+ def align(plan: TripPlan, places: Sequence[str]) -> list[StopMoments]:
105
+ """Match a plan's segments against the journey's planned stops, in order.
106
+
107
+ An itinerary has many departures and arrivals; only the ones at planned stops are
108
+ choices the owner expressed a preference about. The rest are connections, whose
109
+ times the routing decides — and pricing those against the same window charges a
110
+ one-stop itinerary twice for one decision, since the connecting leg also departs
111
+ on a day the window covers.
112
+
113
+ Matched **in order and by place**, never by index: a candidate with two connections
114
+ has more legs than one with none, so position means something different in each.
115
+ Repeated places (out of BUD, home to BUD) are handled by the ordering.
116
+ """
117
+ segments = plan.segments
118
+ if not segments or not places:
119
+ return []
120
+ moments = [StopMoments(places[0], None, segments[0].depart)]
121
+ at = 0
122
+ for index, segment in enumerate(segments):
123
+ if at + 1 >= len(places):
124
+ break
125
+ if segment.destination != places[at + 1]:
126
+ continue # a connection: the routing's business, not a stated preference
127
+ at += 1
128
+ onward = segments[index + 1].depart if index + 1 < len(segments) else None
129
+ moments.append(StopMoments(places[at], segment.arrive, onward))
130
+ return moments
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class DateCost:
135
+ """What one departure moment costs, and whether the moment mattered.
136
+
137
+ `timed` is only for the explanation: writing "departs 2026-09-23 13:45" against a
138
+ window that speaks in whole days would claim a precision the preference never had,
139
+ and the whole point of the itemized table is that every number can be defended.
140
+ """
141
+
142
+ amount: float
143
+ timed: bool = False
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class StopoverRequest:
148
+ """A stopover that is wanted — from the intent, a wishlist, or an answer.
149
+
150
+ `source` matters for the report: a stopover the owner asked for in advance and
151
+ one they accepted when the routing offered it are the same arithmetic but not
152
+ the same story.
153
+ """
154
+
155
+ place: str
156
+ min_nights: int = 1
157
+ max_nights: int = 3
158
+ ideal_nights: int | None = None
159
+ """The length actually wanted. Two nights in Istanbul is a stopover; one is a
160
+ rushed hotel run and four is a small holiday, and the difference is worth pricing
161
+ (§8.4). Inferred from the bounds when it is not stated."""
162
+ cost_at_tolerance: float | None = None
163
+ tolerance_nights: float | None = None
164
+ net_at_ideal: float = 0.0
165
+ """What a stay of the ideal length is worth, net, as one signed number (§8.3).
166
+
167
+ Negative means you would pay for it. Net because a hotel rate is guessable and
168
+ what the place is worth to *you* is not, and only the difference ever ranks
169
+ anything. This is the only cost in the model allowed below zero."""
170
+ source: str = "intent"
171
+ """intent | wishlist | asked"""
172
+
173
+ def bounds(self) -> curve.Bounds:
174
+ return curve.resolve(
175
+ self.min_nights, self.ideal_nights, self.max_nights, spread=self.tolerance_nights
176
+ )
177
+
178
+ def cost_of(self, nights: int, *, scale: float) -> float | None:
179
+ """The whole cost of this stop: what it is worth, and how far off the length is.
180
+
181
+ One function, no per-night rate. The marginal night is the derivative, which
182
+ starts at zero and grows — so the first night past the ideal is nearly free and
183
+ the tenth is not (§8.3).
184
+ """
185
+ amount = self.cost_at_tolerance if self.cost_at_tolerance is not None else scale
186
+ return curve.cost(
187
+ nights,
188
+ self.bounds(),
189
+ scale=amount,
190
+ tolerance=self.tolerance_nights,
191
+ offset=self.net_at_ideal,
192
+ )
193
+
194
+ def breakeven(self, *, scale: float) -> tuple[float, float] | None:
195
+ amount = self.cost_at_tolerance if self.cost_at_tolerance is not None else scale
196
+ if not amount:
197
+ return None
198
+ return curve.breakeven(
199
+ self.bounds(),
200
+ scale=amount,
201
+ tolerance=self.tolerance_nights,
202
+ offset=self.net_at_ideal,
203
+ )
204
+
205
+
206
+ @dataclass(frozen=True, slots=True)
207
+ class StopoverOpportunity:
208
+ """A stopover nobody asked for, because nobody knew it was on offer.
209
+
210
+ Real searches work backwards from the routing: a BUD–Dubai fare turns out to
211
+ connect in Santorini, a BUD–Bogotá one in Punta Cana, and *that* is when the
212
+ traveller decides they want two nights there. Treating such a gap as dead
213
+ layover time silently throws away the best thing about the itinerary, and
214
+ treating it as a wanted stopover invents a holiday nobody agreed to.
215
+
216
+ So it is neither: it is a priced question (§10). `net_ceiling` is the honest form
217
+ of that question — not "do you like Santorini?" but "would two nights there cost
218
+ you more than €200 net?", which is answerable, comparable, and contains no
219
+ lodging rate we had to invent on the owner's behalf (§8.3).
220
+ """
221
+
222
+ place: str
223
+ nights: int
224
+ hours: float
225
+ as_wait_cost: float
226
+ """What the gap costs treated as an involuntary layover."""
227
+ net_ceiling: float
228
+ """The most the stay may cost you, net, and still beat sitting it out.
229
+
230
+ Equal to what the wait costs, because taking the stop replaces that cost with this
231
+ one. Stated as a ceiling on *net* cost rather than a floor on value, since the
232
+ hotel is inside the owner's number now — asking "is a night worth €35?" required
233
+ us to have assumed a lodging rate, which was never ours to assume (§8.3)."""
234
+
235
+ def question(self) -> str:
236
+ return (
237
+ f"This routing sits {self.hours:.0f}h in {self.place}. Taking it as a "
238
+ f"{self.nights}-night stop beats waiting unless the stay costs you more "
239
+ f"than {self.net_ceiling:.0f} net — hotel minus whatever it is worth to "
240
+ f"be there. Take it?"
241
+ )
242
+
243
+
244
+ @dataclass(slots=True)
245
+ class PricingContext:
246
+ """Everything pricing needs that is not the plan itself."""
247
+
248
+ prefs: PreferenceProfile
249
+ """The trip's default valuations. Soft prices only — hard requirements are
250
+ `constraints`, and what missing them costs is `prefs.constraints` (§8.1)."""
251
+ party: Party
252
+ constraints: list[HardConstraint] = field(default_factory=list)
253
+ """Checked, never priced: a plan that misses the conference is not expensive."""
254
+ prefs_by_leg: dict[int, PreferenceProfile] = field(default_factory=dict)
255
+ """Per-leg valuation overrides, keyed by leg index across the whole plan.
256
+
257
+ Not a "mode" — just these numbers with a few changed, because time can be worth
258
+ more on the way to something than on the way home."""
259
+ destinations: frozenset[str] = frozenset()
260
+ """Where the trip is *going*. The gap there is the point of the whole exercise.
261
+
262
+ Without this, a return itinerary's four nights at the destination are read as a
263
+ 92-hour layover and charged as dead time — which is not a rounding error, it is
264
+ the trip itself priced as suffering. Caught the first time the model met a real
265
+ multi-ticket return (AZair, BUD-LIS)."""
266
+ baggage_profile: BaggageProfile = BaggageProfile.CABIN_BAG
267
+ stopovers: dict[str, StopoverRequest] = field(default_factory=dict)
268
+ """Stopovers already wanted, by place — from the intent or an answered question."""
269
+ stopover_interest: dict[str, float] = field(default_factory=dict)
270
+ """Per-search additions to the config wishlist (`prefs.stopover.net`): place ->
271
+ net cost of a stay there, negative for wanted. A routing through one of these is
272
+ a wanted stop without asking, because the question was already answered."""
273
+ stopover_candidate_min_hours: float | None = None
274
+ """Override for `prefs.stopover.candidate_min_hours`; None uses config."""
275
+ offer_stopovers: bool | None = None
276
+ """Override for `prefs.stopover.offer`; None uses config."""
277
+ date_costs: dict[date, float] = field(default_factory=dict)
278
+ """Flexibility cost per outbound/inbound departure date (DESIGN §8.4).
279
+
280
+ Day granularity: what the best departure moment on that date would cost."""
281
+ journey: list[StopWindows] = field(default_factory=list)
282
+ """The planned stops in order, each with whatever windows were stated.
283
+
284
+ There is no outbound and no inbound here. A trip is stops and the legs between
285
+ them; "the return" is a leg home, and an open jaw or a four-city trip is the same
286
+ object with a different list (§8.3). Direction survives only in `Offer`, because a
287
+ round trip is a product airlines sell — not a fact about the journey."""
288
+ departure_cost: Callable[[datetime], DateCost | None] | None = None
289
+ """Legacy single-window entry point, used when no journey is supplied."""
290
+ stopover_length_scale: float = 0.0
291
+ """Scale for the stopover-length curve when a spec does not price its own."""
292
+ duration_cost: Callable[[int], float | None] | None = None
293
+ """Trip length in nights, priced by the intent's own window when it stated one.
294
+
295
+ Departure, arrival and length are three quantities of which any two fix the third,
296
+ so an intent normally states two — and when it states all three, each is priced on
297
+ its own terms rather than one being derived and silently double-counted."""
298
+ """The same preference at the resolution the itinerary actually has.
299
+
300
+ A window may name times as well as dates — "ideally airborne by 09:00, not before
301
+ 06:00" — and then two flights on the same day are not the same choice. Supplied by
302
+ the intent (`TravelIntent.departure_cost`) and consulted first; `date_costs` is
303
+ what remains when a window speaks only in days."""
304
+ preferred_nights: int | None = None
305
+ delay_table: DelayTable = field(default_factory=DelayTable.fallback)
306
+ punctuality: dict[str, PunctualityObservation] = field(default_factory=dict)
307
+ """Keyed by "CARRIER+NUMBER"; empty before Phase 3 (DESIGN §8.5)."""
308
+ regimes: RegimeTable | None = None
309
+ transit_countries: dict[str, str] = field(default_factory=dict)
310
+ """Airport -> ISO country. Kept explicit so the cost model needs no airport DB."""
311
+ lounge_airports: frozenset[str] = frozenset()
312
+ """Airports where the party has lounge access in the terminal actually used."""
313
+ long_haul_hours: float = 6.0
314
+ ancillary_estimate: Band | None = None
315
+ """Baggage/ancillary adjustment from the fee table (M2); None = nothing known."""
316
+
317
+ @property
318
+ def checked_bag(self) -> bool:
319
+ return self.baggage_profile is BaggageProfile.CABIN_PLUS_CHECKED
320
+
321
+ @property
322
+ def offers_stopovers(self) -> bool:
323
+ if self.offer_stopovers is not None:
324
+ return self.offer_stopovers
325
+ return self.prefs.stopover.offer
326
+
327
+ @property
328
+ def candidate_min_hours(self) -> float:
329
+ if self.stopover_candidate_min_hours is not None:
330
+ return self.stopover_candidate_min_hours
331
+ return self.prefs.stopover.candidate_min_hours
332
+
333
+ def interest_in(self, place: str) -> float | None:
334
+ """Net cost of a stay in this place, if it has been valued in advance.
335
+
336
+ Per-search entries win over config. Negative means wanted (§8.3).
337
+ """
338
+ if place in self.stopover_interest:
339
+ return self.stopover_interest[place]
340
+ return self.prefs.stopover.net.get(place)
341
+
342
+ def delay_for(self, segment: Segment) -> DelayEstimate:
343
+ long_haul = segment.duration.total_seconds() / 3600 >= self.long_haul_hours
344
+ prior = self.delay_table.prior_for_hour(
345
+ segment.local_departure_hour, long_haul=long_haul
346
+ )
347
+ key = f"{segment.carrier}{segment.flight_number}"
348
+ return blend(
349
+ prior,
350
+ self.punctuality.get(key),
351
+ pseudo_observations=self.prefs.delay.prior_pseudo_observations,
352
+ )
353
+
354
+ def country_of(self, airport: str) -> str | None:
355
+ return self.transit_countries.get(airport)
356
+
357
+ def prefs_for_leg(self, leg_index: int) -> PreferenceProfile:
358
+ """The valuations that apply to one leg, defaulting to the trip's."""
359
+ return self.prefs_by_leg.get(leg_index, self.prefs)
360
+
361
+
362
+ @dataclass(slots=True)
363
+ class PlanCost:
364
+ """The priced plan: total, itemization, risks, and what had to be assumed."""
365
+
366
+ lines: list[CostLine]
367
+ money_total: Band
368
+ risks: list[RiskAnnotation]
369
+ infeasible_reasons: list[str]
370
+ verdicts: list[FeasibilityVerdict]
371
+ borders: list[PartyBorder]
372
+ notes: list[str]
373
+ violations: list[ConstraintViolation] = field(default_factory=list)
374
+ """Hard requirements this plan breaks (§8.1). Distinct from `infeasible_reasons`
375
+ about connections: one is "you would miss the conference", the other is "you
376
+ would miss the flight"."""
377
+ opportunities: list[StopoverOpportunity] = field(default_factory=list)
378
+ """Stopovers this routing makes possible but nobody has asked for yet (§8.3)."""
379
+ assumptions: AssumptionLog = field(default_factory=AssumptionLog)
380
+ """What had to be decided without being told (§10)."""
381
+
382
+ @property
383
+ def generalized_cost(self) -> Band:
384
+ return band_sum([line.amount for line in self.lines])
385
+
386
+ @property
387
+ def feasible(self) -> bool:
388
+ return not self.infeasible_reasons and not self.violations
389
+
390
+ @property
391
+ def money_full_one_off(self) -> Band:
392
+ """Out-of-pocket including un-amortized authorization costs."""
393
+ extra = sum(b.money_cost_full - b.money_cost for b in self.borders)
394
+ return self.money_total + extra
395
+
396
+ def by_kind(self, kind: CostKind) -> Band:
397
+ return band_sum([line.amount for line in self.lines if line.kind is kind])
398
+
399
+ def explain(self) -> str:
400
+ head = (
401
+ f"generalized {self.generalized_cost} "
402
+ f"(money {self.money_total}"
403
+ + (
404
+ f", one-off {self.money_full_one_off}"
405
+ if self.money_full_one_off.mid != self.money_total.mid
406
+ else ""
407
+ )
408
+ + ")"
409
+ )
410
+ body = "\n".join(f" {line}" for line in self.lines)
411
+ tail = ""
412
+ for violation in self.violations:
413
+ tail += f"\n CONSTRAINT: {violation}"
414
+ for opportunity in self.opportunities:
415
+ tail += f"\n OPPORTUNITY: {opportunity.question()}"
416
+ if self.infeasible_reasons:
417
+ tail += "\n INFEASIBLE: " + "; ".join(self.infeasible_reasons)
418
+ return f"{head}\n{body}{tail}"
419
+
420
+
421
+ # ── individual terms ──────────────────────────────────────────────────────────
422
+
423
+
424
+ def _is_red_eye(segment: Segment, prefs: PreferenceProfile) -> bool:
425
+ hour = segment.local_departure_hour
426
+ return hour >= prefs.discomfort.red_eye_from_hour or hour < prefs.discomfort.red_eye_to_hour
427
+
428
+
429
+ def _segment_time_cost(segment: Segment, prefs: PreferenceProfile) -> tuple[float, str]:
430
+ """Time only: hours you don't get back, at what an hour is worth.
431
+
432
+ The unpleasantness of a red-eye is *not* here. It is a fixed discomfort amount
433
+ (§8.2), because a broken night is a broken night whether the flight is two hours
434
+ or twelve — and because how much you mind it should not scale with your hourly
435
+ rate.
436
+ """
437
+ hours = segment.duration.total_seconds() / 3600
438
+ factor = prefs.time.in_flight_factor
439
+ note = f"{segment} {hours:.1f}h x{factor:g}"
440
+ return hours * factor * prefs.time.wage_rate_per_hour, note
441
+
442
+
443
+ def _overnight_choice(hours: float, prefs: PreferenceProfile) -> tuple[float, str]:
444
+ """Price a night in transit as the cheaper of terminal or airport hotel."""
445
+ terminal = prefs.discomfort.cost(prefs.discomfort.night_in_terminal)
446
+ hotel = (
447
+ prefs.overnight.airport_hotel_cost
448
+ + prefs.overnight.hotel_transfer_hours * prefs.time.wage_rate_per_hour
449
+ )
450
+ if hotel < terminal:
451
+ return hotel, f"airport hotel assumed ({hours:.1f}h wait)"
452
+ return terminal, f"night in terminal assumed ({hours:.1f}h wait)"
453
+
454
+
455
+ def _spans_night(connection: Connection, prefs: PreferenceProfile) -> bool:
456
+ """Does this gap eat a night?
457
+
458
+ Requires both length (a 2-hour wait at 23:00 is a wait, not a night) and
459
+ overlap with the overnight window, so a long daytime layover is not charged a
460
+ hotel and a 22:00–07:00 sit is not charged as ordinary dead time.
461
+ """
462
+ start, end = connection.arrive_segment.arrive, connection.depart_segment.depart
463
+ if (end - start).total_seconds() / 3600 < prefs.overnight.min_night_hours:
464
+ return False
465
+ hours_spanned = int((end - start).total_seconds() // 3600) + 1
466
+ return any(
467
+ (start.hour + offset) % 24 >= prefs.overnight.window_from_hour
468
+ or (start.hour + offset) % 24 < prefs.overnight.window_to_hour
469
+ for offset in range(hours_spanned)
470
+ )
471
+
472
+
473
+ def _stay_nights(connection: Connection) -> int:
474
+ return max(
475
+ 0,
476
+ (
477
+ connection.depart_segment.depart.date() - connection.arrive_segment.arrive.date()
478
+ ).days,
479
+ )
480
+
481
+
482
+ def _requested_stopover(connection: Connection, ctx: PricingContext) -> StopoverRequest | None:
483
+ """Is this gap a wanted stopover, or a layover nobody asked for?
484
+
485
+ Three ways a stay can be wanted: the intent asked for it, the owner's standing
486
+ wishlist covers the place (`stopover_interest` — already an answered question),
487
+ or a question asked mid-search was answered yes, which arrives here as an entry
488
+ in `ctx.stopovers` with `source="asked"`.
489
+ """
490
+ place = connection.at_airport
491
+ nights = _stay_nights(connection)
492
+
493
+ request = ctx.stopovers.get(place)
494
+ if request is not None:
495
+ if request.min_nights <= nights <= request.max_nights:
496
+ return request
497
+ return None
498
+
499
+ # A wishlisted place needs no question: the owner priced it in advance. The
500
+ # length on offer becomes the ideal, since they valued the place and not a
501
+ # particular number of nights.
502
+ net = ctx.interest_in(place)
503
+ if net is not None and nights >= 1:
504
+ return StopoverRequest(
505
+ place=place,
506
+ min_nights=1,
507
+ ideal_nights=nights,
508
+ max_nights=nights,
509
+ net_at_ideal=net,
510
+ source="wishlist",
511
+ )
512
+ return None
513
+
514
+
515
+ def _stopover_opportunity(
516
+ connection: Connection, ctx: PricingContext, *, as_wait_cost: float
517
+ ) -> StopoverOpportunity | None:
518
+ """Would this gap be worth offering as a stopover?
519
+
520
+ Only asked of gaps long enough to be a real stay: below
521
+ `stopover_candidate_min_hours` the question is noise. The break-even value is
522
+ computed from what the gap already costs as a layover, which is what makes the
523
+ question answerable — "worth €35 a night to you?" rather than "fancy Greece?".
524
+ """
525
+ if not ctx.offers_stopovers:
526
+ return None
527
+ hours = connection.gap_minutes / 60
528
+ if hours < ctx.candidate_min_hours:
529
+ return None
530
+
531
+ nights = max(1, _stay_nights(connection))
532
+ # Taking the stop replaces the wait cost with the stop's own net cost, so it wins
533
+ # whenever that net is below what waiting already costs.
534
+ return StopoverOpportunity(
535
+ place=connection.at_airport,
536
+ nights=nights,
537
+ hours=hours,
538
+ as_wait_cost=as_wait_cost,
539
+ net_ceiling=as_wait_cost,
540
+ )
541
+
542
+
543
+ def _stopover_assumption(opportunity: StopoverOpportunity, plan_id: str) -> Assumption:
544
+ """The question, with both answers priced (§10)."""
545
+ nights = opportunity.nights
546
+ return Assumption(
547
+ key=f"stopover:{opportunity.place}:{nights}",
548
+ question=opportunity.question(),
549
+ options=[
550
+ Option(
551
+ label="stay in transit (default)",
552
+ value=0,
553
+ pros="nothing to arrange, no extra spending",
554
+ cons=f"{opportunity.hours:.0f}h of the trip spent in an airport, "
555
+ f"costed at {opportunity.as_wait_cost:.0f}",
556
+ ),
557
+ Option(
558
+ label=f"{nights} night(s) in {opportunity.place}",
559
+ value=nights,
560
+ pros="turns dead time into somewhere you wanted to be; beats "
561
+ f"waiting unless it costs more than {opportunity.net_ceiling:.0f} net",
562
+ cons="a hotel to pay for, bags to move, and the onward ticket "
563
+ "becomes a separate booking risk",
564
+ ),
565
+ ],
566
+ default=0,
567
+ blocking=False,
568
+ affects=[plan_id],
569
+ )
570
+
571
+
572
+ def _constraints_broken_by_missing(
573
+ plan: TripPlan,
574
+ connection: Connection,
575
+ ctx: PricingContext,
576
+ ) -> list[HardConstraint]:
577
+ """Which hard requirements fail if *this* connection is missed.
578
+
579
+ The timing question, asked precisely: missing a connection pushes arrival by
580
+ `recovery_hours` (or overnight, if it was the day's last departure), and only the
581
+ constraints that then break are relevant to this connection. A self-transfer three
582
+ days before a conference breaks nothing; the same self-transfer the night before
583
+ breaks everything.
584
+ """
585
+ if not ctx.constraints:
586
+ return []
587
+
588
+ recovery = timedelta(
589
+ hours=(
590
+ ctx.prefs.delay.recovery_hours_overnight
591
+ if connection.onward_is_last_of_day
592
+ else ctx.prefs.delay.recovery_hours
593
+ )
594
+ )
595
+
596
+ broken: list[HardConstraint] = []
597
+ for constraint in ctx.constraints:
598
+ if constraint.kind is ConstraintKind.DEPART_AFTER:
599
+ continue # a missed connection never makes you leave earlier
600
+ if constraint.kind is ConstraintKind.ARRIVE_BY and constraint.place is not None:
601
+ reached = [s.arrive for s in plan.segments if s.destination == constraint.place]
602
+ if not reached:
603
+ continue
604
+ would_arrive = reached[0] + recovery
605
+ else:
606
+ would_arrive = plan.arrive + recovery
607
+ # Only connections *before* the deadline can threaten it.
608
+ if connection.arrive_segment.arrive > constraint.when:
609
+ continue
610
+ if would_arrive > constraint.effective_deadline:
611
+ broken.append(constraint)
612
+ return broken
613
+
614
+
615
+ def _stake_multiplier(plan: TripPlan, connection: Connection, ctx: PricingContext) -> float:
616
+ """How much extra cushion this connection deserves, given what it threatens (§8.9).
617
+
618
+ The honest replacement for the old business-mode buffer multiplier: cushion is
619
+ bought where something is actually at risk, not everywhere on a trip that happens
620
+ to contain a deadline.
621
+ """
622
+ broken = _constraints_broken_by_missing(plan, connection, ctx)
623
+ if not broken:
624
+ return 1.0
625
+ weight = max(c.stake_weight(ctx.prefs.constraints) for c in broken)
626
+ ceiling = ctx.prefs.constraints.stake_buffer_max_multiplier
627
+ return 1.0 + weight * (ceiling - 1.0)
628
+
629
+
630
+ def _expected_violation_cost(
631
+ plan: TripPlan,
632
+ connection: Connection,
633
+ verdict: FeasibilityVerdict,
634
+ inbound_delay: DelayEstimate,
635
+ ctx: PricingContext,
636
+ ) -> float:
637
+ """P(this connection is missed) x what missing it would break.
638
+
639
+ "Definitely arrive by 20:00" cannot be checked against a schedule alone: a plan
640
+ that *intends* to land at 14:00 with a 15% chance of arriving tomorrow satisfies
641
+ the timetable and fails the requirement. Stakes turn that probability into money,
642
+ so a fragile plan loses on the objective instead of needing a flag to forbid it.
643
+ """
644
+ broken = _constraints_broken_by_missing(plan, connection, ctx)
645
+ if not broken:
646
+ return 0.0
647
+
648
+ cushion = verdict.slack_minutes + verdict.breakdown.delay_allowance_minutes
649
+ probability = p_miss(inbound_delay, cushion)
650
+ if probability <= 0:
651
+ return 0.0
652
+ return probability * sum(c.cost_of_missing(ctx.prefs.constraints) for c in broken)
653
+
654
+
655
+ def _segments_with_leg(plan: TripPlan) -> list[tuple[int, Segment]]:
656
+ """Every segment, tagged with its leg index across the whole plan.
657
+
658
+ Leg boundaries are where valuations may change: the way to something and the way
659
+ home are different legs and an hour on each can be worth different amounts.
660
+ """
661
+ out: list[tuple[int, Segment]] = []
662
+ leg_index = 0
663
+ for offer in plan.offers:
664
+ for leg in offer.legs:
665
+ for segment in leg.segments:
666
+ out.append((leg_index, segment))
667
+ leg_index += 1
668
+ return out
669
+
670
+
671
+ def price_plan(plan: TripPlan, ctx: PricingContext) -> PlanCost:
672
+ """Price a plan into a generalized cost with a full itemization."""
673
+ prefs = ctx.prefs
674
+ wage = prefs.time.wage_rate_per_hour
675
+ lines: list[CostLine] = []
676
+ risks: list[RiskAnnotation] = []
677
+ discomfort: list[tuple[float, str]] = []
678
+ expected_misconnect = 0.0
679
+ expected_violation = 0.0
680
+ infeasible: list[str] = []
681
+ verdicts: list[FeasibilityVerdict] = []
682
+ party_borders: list[PartyBorder] = []
683
+ notes: list[str] = []
684
+ opportunities: list[StopoverOpportunity] = []
685
+ assumptions = AssumptionLog()
686
+
687
+ # ── money ────────────────────────────────────────────────────────────────
688
+ fares = plan.money_out_of_pocket
689
+ if fares is None:
690
+ infeasible.append(
691
+ "an offer has no reference rate — uncomparable, excluded from ranking"
692
+ )
693
+ money = Band.zero()
694
+ else:
695
+ money = Band.exact(fares)
696
+ lines.append(
697
+ CostLine(CostKind.FARE, money, f"{len(plan.offers)} ticket(s), normalized")
698
+ )
699
+ if ctx.ancillary_estimate is not None:
700
+ lines.append(
701
+ CostLine(
702
+ CostKind.ANCILLARY,
703
+ ctx.ancillary_estimate,
704
+ f"baggage under {ctx.baggage_profile}",
705
+ )
706
+ )
707
+ money = money + ctx.ancillary_estimate
708
+
709
+ # ── time in the air, at each leg's own valuation ─────────────────────────
710
+ for leg_index, segment in _segments_with_leg(plan):
711
+ leg_prefs = ctx.prefs_for_leg(leg_index)
712
+ cost, note = _segment_time_cost(segment, leg_prefs)
713
+ if leg_prefs is not prefs:
714
+ note += f" [{leg_prefs.label or f'leg {leg_index}'}]"
715
+ lines.append(CostLine(CostKind.TRAVEL_TIME, Band.exact(cost), note))
716
+
717
+ for _, segment in _segments_with_leg(plan):
718
+ if _is_red_eye(segment, prefs):
719
+ discomfort.append(
720
+ (
721
+ prefs.discomfort.cost(prefs.discomfort.red_eye_per_flight),
722
+ f"{segment.carrier}{segment.flight_number} departs "
723
+ f"{segment.local_departure_hour:02d}:xx — a night lost",
724
+ )
725
+ )
726
+
727
+ # ── expected delay ───────────────────────────────────────────────────────
728
+ for leg_index, segment in _segments_with_leg(plan):
729
+ leg_prefs = ctx.prefs_for_leg(leg_index)
730
+ wage = leg_prefs.time.wage_rate_per_hour
731
+ estimate = ctx.delay_for(segment)
732
+ cost = estimate.mean_min / 60 * wage * leg_prefs.time.layover_factor
733
+ lines.append(
734
+ CostLine(
735
+ CostKind.EXPECTED_DELAY,
736
+ Band.exact(cost),
737
+ f"{segment.carrier}{segment.flight_number} "
738
+ f"{segment.local_departure_hour:02d}:xx dep, "
739
+ f"{estimate.mean_min:.0f}m expected slip ({estimate.source})",
740
+ )
741
+ )
742
+
743
+ # ── connections: waits, stopovers, borders, feasibility, risk ────────────
744
+
745
+ for connection in plan.connections():
746
+ if connection.at_airport in ctx.destinations:
747
+ # The stay at the destination is a stop like any other (§8.3) — it just
748
+ # happens to be the point of the trip. What it is *not* is a transfer:
749
+ # there is no misconnect to fear (missing your own return flight is a
750
+ # different problem) and no self-transfer hassle in buying the two
751
+ # directions separately, so none of the machinery below applies.
752
+ if not connection.within_offer:
753
+ # The two directions were bought apart. Not a misconnect — nothing
754
+ # connects here but the stay — so it is annotated, not priced: the
755
+ # exposure is that the bookings fail independently, and a cancelled
756
+ # outbound leaves a return that is still yours to deal with. Pricing
757
+ # it would need a cancellation prior nobody has (§8.7).
758
+ risks.append(
759
+ RiskAnnotation(
760
+ kind=RiskKind.SEPARATE_PNR,
761
+ detail=(
762
+ f"{connection.at_airport}: outbound and return bought "
763
+ "separately — one cancellation does not release the other, "
764
+ "and bags are paid for twice"
765
+ ),
766
+ )
767
+ )
768
+ stay = ctx.stopovers.get(connection.at_airport)
769
+ if stay is not None:
770
+ nights = max(0, _stay_nights(connection))
771
+ net = stay.cost_of(nights, scale=ctx.stopover_length_scale)
772
+ if net:
773
+ lines.append(
774
+ CostLine(
775
+ CostKind.STOPOVER,
776
+ Band.exact(net),
777
+ f"{connection.at_airport} {nights}n "
778
+ f"vs ideal {stay.bounds().ideal:g}n",
779
+ )
780
+ )
781
+ continue
782
+ stay_hours = connection.gap_minutes / 60
783
+ inbound_delay = ctx.delay_for(connection.arrive_segment)
784
+ country = ctx.country_of(connection.at_airport)
785
+
786
+ border: PartyBorder | None = None
787
+ border_minutes = 0.0
788
+ entry_required = False
789
+ if country and ctx.regimes is not None:
790
+ border = borders_mod.resolve_party(
791
+ ctx.party,
792
+ country,
793
+ table=ctx.regimes,
794
+ departure=connection.arrive_segment.arrive.date(),
795
+ wage_rate_per_hour=wage,
796
+ )
797
+ party_borders.append(border)
798
+ border_minutes = border.added_time_min
799
+ entry_required = border.regime is Regime.ENTRY_REQUIRED
800
+ if border_minutes:
801
+ lines.append(
802
+ CostLine(
803
+ CostKind.BORDER,
804
+ Band.exact(border_minutes / 60 * wage),
805
+ f"{border.country} {border.regime}: {border_minutes:.0f}m "
806
+ f"(party maximum)",
807
+ )
808
+ )
809
+ for per in border.per_traveler:
810
+ if per.money_cost:
811
+ lines.append(
812
+ CostLine(
813
+ CostKind.BORDER,
814
+ Band.exact(per.money_cost),
815
+ per.note,
816
+ traveler_id=per.traveler_id,
817
+ )
818
+ )
819
+ if per.authorization_status.value == "obtainable":
820
+ risks.append(
821
+ RiskAnnotation(
822
+ kind=RiskKind.AUTHORIZATION_PENDING,
823
+ detail=per.note,
824
+ traveler_id=per.traveler_id,
825
+ )
826
+ )
827
+ if not border.feasible:
828
+ infeasible.extend(border.blocking_notes)
829
+ if border.regime is Regime.UNKNOWN:
830
+ risks.append(
831
+ RiskAnnotation(
832
+ kind=RiskKind.UNKNOWN_TRANSIT_REGIME,
833
+ detail=(
834
+ f"{country}: transit regime not in the table — ask, don't assume"
835
+ ),
836
+ )
837
+ )
838
+
839
+ request = _requested_stopover(connection, ctx)
840
+
841
+ # Feasibility comes before pricing: an infeasible connection is not an
842
+ # expensive plan, it is not a plan.
843
+ stake_multiplier = _stake_multiplier(plan, connection, ctx)
844
+ verdict = assess(
845
+ connection,
846
+ prefs.feasibility,
847
+ checked_bag=ctx.checked_bag,
848
+ inbound_delay=inbound_delay,
849
+ border_minutes=border_minutes,
850
+ entry_required=entry_required,
851
+ stopover_requested=request is not None,
852
+ stake_multiplier=stake_multiplier,
853
+ )
854
+ verdicts.append(verdict)
855
+ if not verdict.feasible:
856
+ infeasible.append(f"{connection}: {verdict.reason}")
857
+
858
+ if request is not None:
859
+ nights = max(
860
+ 1,
861
+ (
862
+ connection.depart_segment.depart.date()
863
+ - connection.arrive_segment.arrive.date()
864
+ ).days,
865
+ )
866
+ # One line, one function: what the stay is worth plus how far its length
867
+ # is from the one that was wanted (§8.3). No per-night rate anywhere.
868
+ net = request.cost_of(nights, scale=ctx.stopover_length_scale)
869
+ wanted = request.bounds().ideal
870
+ note = f"{connection.at_airport} {nights}n"
871
+ if nights != wanted:
872
+ note += f" vs ideal {wanted:g}n"
873
+ if request.net_at_ideal:
874
+ worth = -request.net_at_ideal
875
+ note += f" (worth {worth:.0f} at best)" if worth > 0 else ""
876
+ if net:
877
+ lines.append(CostLine(CostKind.STOPOVER, Band.exact(net), note))
878
+ # Requested stopover hours leave the time account entirely (§8.3).
879
+ else:
880
+ wait_factor = prefs.time.layover_factor
881
+ note = f"{connection.at_airport} {stay_hours:.1f}h wait"
882
+ if connection.gap_minutes < prefs.discomfort.tight_connection_minutes:
883
+ # The stress of running for a gate is a fixed unpleasantness, not a
884
+ # multiple of 45 minutes — and it should not grow because your hourly
885
+ # rate did.
886
+ discomfort.append(
887
+ (
888
+ prefs.discomfort.cost(prefs.discomfort.tight_connection),
889
+ f"tight connection at {connection.at_airport} "
890
+ f"({connection.gap_minutes:.0f}m)",
891
+ )
892
+ )
893
+
894
+ if _spans_night(connection, prefs):
895
+ # Per night, per traveller: two people stuck for two nights is four
896
+ # bad nights, not one.
897
+ nights_stuck = max(1, _stay_nights(connection))
898
+ per_night, overnight_note = _overnight_choice(stay_hours, prefs)
899
+ wait_cost = per_night * nights_stuck * ctx.party.size
900
+ lines.append(
901
+ CostLine(
902
+ CostKind.INVOLUNTARY_WAIT,
903
+ Band.exact(wait_cost),
904
+ f"{connection.at_airport}: {overnight_note}, "
905
+ f"{nights_stuck} night(s) x{ctx.party.size} traveller(s)",
906
+ )
907
+ )
908
+ opportunity = _stopover_opportunity(connection, ctx, as_wait_cost=wait_cost)
909
+ if opportunity is not None:
910
+ opportunities.append(opportunity)
911
+ assumptions.add(_stopover_assumption(opportunity, plan.plan_id))
912
+ else:
913
+ dead_time = stay_hours * wait_factor * wage
914
+ total_wait_cost = 0.0
915
+ lounge_money = 0.0
916
+ lounge_notes: list[str] = []
917
+ for traveler in ctx.party.travelers:
918
+ decision = lounge_mod.decide(
919
+ traveler,
920
+ wait_hours=stay_hours,
921
+ dead_time_cost=dead_time,
922
+ has_lounge_here=connection.at_airport in ctx.lounge_airports,
923
+ prefs=prefs.lounge,
924
+ policy=prefs.lounge.use,
925
+ )
926
+ total_wait_cost += dead_time * decision.wait_factor
927
+ lounge_money += decision.cost
928
+ if decision.used:
929
+ lounge_notes.append(f"{traveler.id}: {decision.note}")
930
+ lines.append(
931
+ CostLine(
932
+ CostKind.INVOLUNTARY_WAIT,
933
+ Band.exact(total_wait_cost),
934
+ f"{note} x{ctx.party.size} traveller(s)",
935
+ )
936
+ )
937
+ if lounge_money or lounge_notes:
938
+ lines.append(
939
+ CostLine(
940
+ CostKind.LOUNGE,
941
+ Band.exact(lounge_money),
942
+ "; ".join(lounge_notes) or "lounge entry",
943
+ )
944
+ )
945
+ opportunity = _stopover_opportunity(
946
+ connection, ctx, as_wait_cost=total_wait_cost + lounge_money
947
+ )
948
+ if opportunity is not None:
949
+ opportunities.append(opportunity)
950
+ assumptions.add(_stopover_assumption(opportunity, plan.plan_id))
951
+
952
+ # Ground transfer for an airport change — free of hassle only when the
953
+ # stopover was wanted, because then you were leaving the airport anyway.
954
+ if connection.airport_change:
955
+ transfer_money = prefs.ground.default_metro_transfer_cost * ctx.party.size
956
+ transfer_time = (
957
+ prefs.ground.default_metro_transfer_hours
958
+ * prefs.time.ground_transfer_factor
959
+ * wage
960
+ )
961
+ if request is not None:
962
+ lines.append(
963
+ CostLine(
964
+ CostKind.GROUND_TRANSFER,
965
+ Band.exact(transfer_money),
966
+ f"{connection.arrive_segment.destination}->"
967
+ f"{connection.depart_segment.origin} ground ticket "
968
+ f"(stopover requested: part of the trip, no hassle premium)",
969
+ )
970
+ )
971
+ else:
972
+ lines.append(
973
+ CostLine(
974
+ CostKind.GROUND_TRANSFER,
975
+ Band.exact(transfer_money + transfer_time),
976
+ f"{connection.arrive_segment.destination}->"
977
+ f"{connection.depart_segment.origin} transfer + time",
978
+ )
979
+ )
980
+ discomfort.append(
981
+ (
982
+ prefs.discomfort.cost(prefs.discomfort.airport_change),
983
+ f"airport change {connection.arrive_segment.destination}->"
984
+ f"{connection.depart_segment.origin}",
985
+ )
986
+ )
987
+ risks.append(
988
+ RiskAnnotation(
989
+ kind=RiskKind.AIRPORT_CHANGE,
990
+ detail=f"{connection.arrive_segment.destination} to "
991
+ f"{connection.depart_segment.origin} on separate tickets",
992
+ )
993
+ )
994
+
995
+ # ── unprotected connections: unpleasantness, then arithmetic ──────────
996
+ if connection.is_protected:
997
+ # A protected connection can still be missed, and if it stands before a
998
+ # hard constraint that matters — the airline rebooks you, but not
999
+ # necessarily in time for the conference.
1000
+ expected_violation += _expected_violation_cost(
1001
+ plan, connection, verdict, inbound_delay, ctx
1002
+ )
1003
+ continue
1004
+
1005
+ if connection.protection is Protection.GUARANTEED_SELF_TRANSFER:
1006
+ discomfort.append(
1007
+ (
1008
+ prefs.discomfort.cost(prefs.discomfort.third_party_guarantee),
1009
+ f"{connection.at_airport}: guaranteed self-transfer",
1010
+ )
1011
+ )
1012
+ risks.append(
1013
+ RiskAnnotation(
1014
+ kind=RiskKind.THIRD_PARTY_GUARANTEE,
1015
+ detail=f"{connection.at_airport}: separate tickets, aggregator guarantee",
1016
+ )
1017
+ )
1018
+ else:
1019
+ discomfort.append(
1020
+ (
1021
+ prefs.discomfort.cost(
1022
+ prefs.discomfort.self_transfer + prefs.discomfort.separate_ticket
1023
+ ),
1024
+ f"{connection.at_airport}: self-transfer on separate tickets",
1025
+ )
1026
+ )
1027
+ risks.append(
1028
+ RiskAnnotation(
1029
+ kind=RiskKind.SEPARATE_PNR,
1030
+ detail=f"{connection.at_airport}: self-transfer, no protection",
1031
+ )
1032
+ )
1033
+ if entry_required:
1034
+ discomfort.append(
1035
+ (
1036
+ prefs.discomfort.cost(prefs.discomfort.border_entry_exit),
1037
+ f"{connection.at_airport}: border inside a self-transfer",
1038
+ )
1039
+ )
1040
+ risks.append(
1041
+ RiskAnnotation(
1042
+ kind=RiskKind.ENTRY_EXIT_TRANSIT,
1043
+ detail=f"{connection.at_airport}: border in the middle of a self-transfer",
1044
+ )
1045
+ )
1046
+ if ctx.checked_bag:
1047
+ discomfort.append(
1048
+ (
1049
+ prefs.discomfort.cost(prefs.discomfort.bag_recheck),
1050
+ f"{connection.at_airport}: claim and re-check the bag",
1051
+ )
1052
+ )
1053
+
1054
+ # Total absorbing time: slack beyond the buffer plus the allowance the buffer
1055
+ # already contains (§8.9) — see p_miss on why bare slack is wrong.
1056
+ cushion = verdict.slack_minutes + verdict.breakdown.delay_allowance_minutes
1057
+ probability = p_miss(inbound_delay, cushion)
1058
+ replacement = prefs.delay.misconnect_replacement_cost * ctx.party.size
1059
+ consequence = replacement
1060
+ if connection.onward_is_last_of_day:
1061
+ overnight_cost, _ = _overnight_choice(8.0, prefs)
1062
+ consequence += overnight_cost * ctx.party.size + 24 * 0.5 * wage
1063
+ expected_misconnect += probability * consequence
1064
+ expected_violation += _expected_violation_cost(
1065
+ plan, connection, verdict, inbound_delay, ctx
1066
+ )
1067
+ notes.append(
1068
+ f"{connection.at_airport}: P(miss)={probability:.0%} on "
1069
+ f"{cushion:.0f}m cushion, consequence ~{consequence:.0f}"
1070
+ )
1071
+
1072
+ # ── flexibility ──────────────────────────────────────────────────────────
1073
+ moments = align(plan, [stop.place for stop in ctx.journey])
1074
+ for stop, actual in zip(ctx.journey, moments, strict=False):
1075
+ for verb, when, ask in (
1076
+ ("into", actual.arrive, stop.arrive),
1077
+ ("out of", actual.depart, stop.depart),
1078
+ ):
1079
+ if when is None or ask is None:
1080
+ continue
1081
+ priced = ask(when)
1082
+ if priced is None or not priced.amount:
1083
+ continue
1084
+ stamp = f"{when:%Y-%m-%d %H:%M}" if priced.timed else f"{when.date()}"
1085
+ lines.append(
1086
+ CostLine(
1087
+ CostKind.DATE_FLEXIBILITY,
1088
+ Band.exact(priced.amount),
1089
+ f"{verb} {stop.place} {stamp}",
1090
+ )
1091
+ )
1092
+ if not ctx.journey and plan.segments:
1093
+ # No journey stated: the older single-window path, outbound departure only.
1094
+ first = plan.segments[0].depart
1095
+ priced = ctx.departure_cost(first) if ctx.departure_cost else None
1096
+ cost = priced.amount if priced else ctx.date_costs.get(first.date())
1097
+ if cost:
1098
+ stamp = f"{first:%Y-%m-%d %H:%M}" if priced and priced.timed else f"{first.date()}"
1099
+ lines.append(
1100
+ CostLine(CostKind.DATE_FLEXIBILITY, Band.exact(cost), f"leaves {stamp}")
1101
+ )
1102
+ # Trip length is only defined for a plan that comes home. A one-way — or the
1103
+ # outbound half of a round trip, which is what a Google Flights row is — has no
1104
+ # duration to deviate from, and charging one would punish every candidate
1105
+ # identically while looking like a real cost.
1106
+ returns_home = plan.segments[-1].destination == plan.segments[0].origin
1107
+ if returns_home and (ctx.duration_cost or ctx.preferred_nights is not None):
1108
+ nights = (plan.arrive.date() - plan.depart.date()).days
1109
+ cost = ctx.duration_cost(nights) if ctx.duration_cost else None
1110
+ note = f"{nights}n vs the stated length"
1111
+ if cost is None and ctx.preferred_nights is not None:
1112
+ # No window, only a preferred length: the standing per-night rate, which
1113
+ # is asymmetric because an extra day away and a day short of the trip you
1114
+ # planned are different kinds of loss.
1115
+ cost = prefs.flexibility.duration_cost(nights, ctx.preferred_nights)
1116
+ direction = "away longer" if nights > ctx.preferred_nights else "shorter"
1117
+ note = f"{nights}n vs preferred {ctx.preferred_nights}n ({direction})"
1118
+ if cost:
1119
+ lines.append(CostLine(CostKind.DURATION_DEVIATION, Band.exact(cost), note))
1120
+
1121
+ # ── hard constraints: checked, not priced (§8.1) ─────────────────────────
1122
+ violations = check_constraints(plan, ctx.constraints)
1123
+
1124
+ # ── discomfort: what the trip costs in unpleasantness (§8.2) ─────────────
1125
+ if discomfort:
1126
+ total_discomfort = sum(amount for amount, _ in discomfort)
1127
+ lines.append(
1128
+ CostLine(
1129
+ CostKind.DISCOMFORT,
1130
+ Band.exact(total_discomfort * ctx.party.size),
1131
+ "; ".join(note for _, note in discomfort)
1132
+ + (f" (x{ctx.party.size} travellers)" if ctx.party.size > 1 else "")
1133
+ + (
1134
+ f" [comfort scale {prefs.discomfort.scale:g}]"
1135
+ if prefs.discomfort.scale != 1.0
1136
+ else ""
1137
+ ),
1138
+ )
1139
+ )
1140
+
1141
+ # ── risk: probabilities times consequences, nothing else (§8.7) ──────────
1142
+ # No hassle terms and no floor: unpleasantness is priced above as discomfort, and
1143
+ # the stakes of a hard constraint are what make a risky plan lose.
1144
+ if expected_misconnect or expected_violation:
1145
+ detail = f"expected misconnection {expected_misconnect:.0f}"
1146
+ if expected_violation:
1147
+ detail += f" + expected constraint violation {expected_violation:.0f}"
1148
+ lines.append(
1149
+ CostLine(
1150
+ CostKind.RISK_PREMIUM,
1151
+ Band.exact(expected_misconnect + expected_violation),
1152
+ detail,
1153
+ )
1154
+ )
1155
+
1156
+ return PlanCost(
1157
+ lines=lines,
1158
+ money_total=money,
1159
+ risks=risks,
1160
+ infeasible_reasons=infeasible,
1161
+ verdicts=verdicts,
1162
+ borders=party_borders,
1163
+ notes=notes,
1164
+ opportunities=opportunities,
1165
+ assumptions=assumptions,
1166
+ violations=violations,
1167
+ )
1168
+
1169
+
1170
+ def breakeven_wage_rate(
1171
+ plan_a: TripPlan,
1172
+ plan_b: TripPlan,
1173
+ ctx: PricingContext,
1174
+ *,
1175
+ low: float = 0.0,
1176
+ high: float = 500.0,
1177
+ tolerance: float = 0.05,
1178
+ ) -> float | None:
1179
+ """The hourly rate at which two plans swap places (DESIGN §8.8).
1180
+
1181
+ Because `wage_rate_per_hour` is a guess, every report states this: "#1 and #2
1182
+ trade places if your time is worth less than €4.50/h" tells the owner whether
1183
+ a ranking is a finding or an artifact. Returns None when no crossing exists in
1184
+ the range — the ordering does not depend on the rate at all.
1185
+ """
1186
+
1187
+ def delta(rate: float) -> float:
1188
+ probe = replace(
1189
+ ctx, prefs=ctx.prefs.with_overrides({"time": {"wage_rate_per_hour": rate}})
1190
+ )
1191
+ return (
1192
+ price_plan(plan_a, probe).generalized_cost.mid
1193
+ - price_plan(plan_b, probe).generalized_cost.mid
1194
+ )
1195
+
1196
+ low_delta, high_delta = delta(low), delta(high)
1197
+ if (low_delta > 0) == (high_delta > 0):
1198
+ return None
1199
+ for _ in range(60):
1200
+ mid = (low + high) / 2
1201
+ if high - low < tolerance:
1202
+ return round(mid, 2)
1203
+ if (delta(mid) > 0) == (low_delta > 0):
1204
+ low = mid
1205
+ else:
1206
+ high = mid
1207
+ return round((low + high) / 2, 2)