glidepath 0.2.0__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.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,609 @@
1
+ """Monte Carlo path runner and success metrics (roadmap 7.3; planning §5.2).
2
+
3
+ :func:`run_paths` projects a plan N times through the one engine step
4
+ function — path *i* is an ordinary :func:`~glidepath.core.engine.run`
5
+ under ``replace(config, path=i)``, so its randomness is exactly
6
+ determined by ``(config.seed, i)`` and any single path is individually
7
+ re-runnable (planning §4.6). Each path reduces to a
8
+ :class:`PathOutcome` — the ruin signal, the household's closing
9
+ balance per period (what the percentile bands chart, roadmap 9.13),
10
+ and the ending balance; the full period ledgers are dropped, so a
11
+ many-path run holds one projection's snapshots at a time.
12
+
13
+ Success metrics over the outcomes (planning §5.2):
14
+
15
+ - **probability of ruin** — the fraction of paths reporting a period
16
+ with unmet need. The engine's per-period ``shortfall`` is the ruin
17
+ signal by design: it survives gross-defined strategies that ignore
18
+ the need (planning §5.2), and it covers planned outflows as well as
19
+ decumulation spending.
20
+ - **ending-pot percentiles** — order statistics of the paths' final
21
+ nominal balances, linearly interpolated. CPI is deterministic across
22
+ paths (the single-inflation-truth rule), so nominal and real
23
+ percentiles rank paths identically; deflate by the final period's
24
+ inflation factor for today's money.
25
+ - **sustainable income** (:func:`sustainable_income`) — the highest
26
+ starting net withdrawal meeting the target
27
+ (:class:`SustainableIncomeSearch`): a descending scan over the
28
+ bracket finds the highest succeeding scan point, then bisection
29
+ refines upward within the scan cell above it. Under a Monte Carlo
30
+ config a candidate meets by success rate over its seeded paths;
31
+ under a deterministic config by a single run with no unmet need —
32
+ the same §5.2 ruin signal either way. For a strategy whose
33
+ success is monotone in the spending level — the default fixed-real
34
+ strategy, and the gross-defined strategies whose draws ignore the
35
+ need — the result is exact to the search tolerance. A strategy with
36
+ adjustment triggers (guardrails, roadmap 5.3) can make success
37
+ non-monotone: there the result is still never below the highest
38
+ succeeding scan point, but a success island narrower than one scan
39
+ step can be missed — raise ``scan_steps`` to tighten the
40
+ resolution. Every probe reuses the same seed (common random
41
+ numbers), so candidates differ only by the spending level and the
42
+ search is reproducible. Probe plans exist only inside the search:
43
+ the synthetic spending "fact" they carry is never part of any
44
+ returned result.
45
+ """
46
+
47
+ from dataclasses import dataclass, replace
48
+ from datetime import UTC, datetime, time
49
+ from decimal import Decimal
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ from glidepath.core.config import EngineError, RunMode
53
+ from glidepath.core.engine import run
54
+ from glidepath.core.entities import SpendingPlan
55
+ from glidepath.core.money import Money
56
+ from glidepath.core.provenance import Fact
57
+
58
+ if TYPE_CHECKING:
59
+ from collections.abc import Iterator, Sequence
60
+ from concurrent.futures import Executor
61
+
62
+ from glidepath.core.config import RunConfig
63
+ from glidepath.core.entities import Household
64
+ from glidepath.core.periods import Period
65
+ from glidepath.core.provenance import Assumption, AssumptionKey, AssumptionSet
66
+ from glidepath.core.region import Region
67
+ from glidepath.core.results import ProjectionResult, RunProvenance
68
+
69
+ _ZERO = Money(Decimal(0))
70
+ _ONE = Decimal(1)
71
+ _TWO = Decimal(2)
72
+ _HUNDRED = Decimal(100)
73
+ _DEFAULT_TOLERANCE = Money(Decimal(100))
74
+ """Default bisection tolerance: £100 of annual spending (planning §5.2)."""
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class PathOutcome:
79
+ """One Monte Carlo path, reduced to its success signals.
80
+
81
+ ``first_shortfall_period`` is the first period whose need went
82
+ unmet — ``None`` on a path that never fell short.
83
+ ``closing_balances`` is the household's total closing balance per
84
+ period in period order, nominal — what the balances chart's
85
+ percentile bands read (roadmap 9.13) — and ``ending_balance`` is
86
+ its final entry, kept as its own field for the ending-pot metrics.
87
+ Re-run the path itself with ``run(plan, assumptions, region,
88
+ replace(config, path=path))``.
89
+ """
90
+
91
+ path: int
92
+ first_shortfall_period: Period | None
93
+ ending_balance: Money
94
+ closing_balances: tuple[Money, ...] = ()
95
+
96
+ def __post_init__(self) -> None:
97
+ """Reject a negative path index or inconsistent balances."""
98
+ if self.path < 0:
99
+ msg = f"PathOutcome.path must be non-negative, got {self.path}"
100
+ raise ValueError(msg)
101
+ if self.ending_balance < _ZERO:
102
+ msg = "PathOutcome.ending_balance must be non-negative"
103
+ raise ValueError(msg)
104
+ if self.closing_balances and self.closing_balances[-1] != self.ending_balance:
105
+ msg = "PathOutcome.closing_balances must end at ending_balance"
106
+ raise ValueError(msg)
107
+
108
+ @property
109
+ def ruined(self) -> bool:
110
+ """Whether any period's need went unmet on this path."""
111
+ return self.first_shortfall_period is not None
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class MonteCarloResult:
116
+ """N paths' outcomes with the success metrics over them (§5.2).
117
+
118
+ ``config`` and ``provenance`` are the §4.6 manifest side: the
119
+ seed, mode, and every assumption any path read — the union across
120
+ paths, since a balance-dependent read (natural-yield pricing,
121
+ roadmap 5.3) can fire on some paths only — so the whole run is
122
+ reproducible from this result plus the plan.
123
+ """
124
+
125
+ outcomes: tuple[PathOutcome, ...]
126
+ config: RunConfig
127
+ provenance: RunProvenance
128
+
129
+ def __post_init__(self) -> None:
130
+ """Require at least one path."""
131
+ if not self.outcomes:
132
+ msg = "MonteCarloResult needs at least one path outcome"
133
+ raise ValueError(msg)
134
+
135
+ @property
136
+ def path_count(self) -> int:
137
+ """How many paths were projected."""
138
+ return len(self.outcomes)
139
+
140
+ @property
141
+ def probability_of_ruin(self) -> Decimal:
142
+ """The fraction of paths reporting a period with unmet need."""
143
+ ruined = sum(1 for outcome in self.outcomes if outcome.ruined)
144
+ return Decimal(ruined) / Decimal(self.path_count)
145
+
146
+ @property
147
+ def success_rate(self) -> Decimal:
148
+ """The complement of :attr:`probability_of_ruin`."""
149
+ return _ONE - self.probability_of_ruin
150
+
151
+ def ending_pot_percentile(self, percentile: Decimal) -> Money:
152
+ """The ending-balance percentile over paths, in [0, 100].
153
+
154
+ Linear interpolation between order statistics: rank
155
+ ``(count - 1) * percentile / 100`` over the sorted balances,
156
+ fractional ranks interpolating between the two neighbours —
157
+ exact ``Decimal`` arithmetic, quantized as a presentation
158
+ value (planning §4.6).
159
+
160
+ Raises:
161
+ ValueError: If ``percentile`` lies outside [0, 100].
162
+ """
163
+ return _interpolated_percentile(
164
+ [outcome.ending_balance for outcome in self.outcomes], percentile
165
+ )
166
+
167
+ def balance_percentile(self, percentile: Decimal) -> tuple[Money, ...]:
168
+ """The per-period closing-balance percentile over paths (9.13).
169
+
170
+ One entry per projected period, in period order — the same
171
+ order-statistic interpolation as
172
+ :meth:`ending_pot_percentile`, applied period by period over
173
+ the paths' nominal household balances. CPI is deterministic
174
+ across paths (module docstring), so deflating each entry by
175
+ the period's price level presents the band in today's money.
176
+
177
+ Raises:
178
+ ValueError: If ``percentile`` lies outside [0, 100], or
179
+ the outcomes carry differing period counts.
180
+ """
181
+ [values] = self.balance_percentiles((percentile,))
182
+ return values
183
+
184
+ def balance_percentiles(
185
+ self, percentiles: Sequence[Decimal]
186
+ ) -> tuple[tuple[Money, ...], ...]:
187
+ """Several balance percentiles from one sort per period (9.24).
188
+
189
+ One :meth:`balance_percentile` row per requested percentile,
190
+ in request order. The fan chart reads nine percentiles at
191
+ once; interpolating them all from a single sorted balance
192
+ vector per period keeps the cost one sort rather than one per
193
+ percentile — the shells build their chart view models on the
194
+ GUI thread, where a 10,000-path result sorted nine times over
195
+ every period is a visible stall.
196
+
197
+ Raises:
198
+ ValueError: If any percentile lies outside [0, 100], or
199
+ the outcomes carry differing period counts.
200
+ """
201
+ for percentile in percentiles:
202
+ _check_percentile(percentile)
203
+ lengths = {len(outcome.closing_balances) for outcome in self.outcomes}
204
+ if len(lengths) != 1:
205
+ msg = "balance_percentiles needs every path to cover the same periods"
206
+ raise ValueError(msg)
207
+ by_period = [
208
+ sorted(outcome.closing_balances[index] for outcome in self.outcomes)
209
+ for index in range(lengths.pop())
210
+ ]
211
+ return tuple(
212
+ tuple(_sorted_percentile(ordered, percentile) for ordered in by_period)
213
+ for percentile in percentiles
214
+ )
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class PathParallelism:
219
+ """How :func:`run_paths` spreads its paths over workers (§5.2).
220
+
221
+ ``executor`` runs path chunks — a ``ProcessPoolExecutor`` for real
222
+ speedup (the engine is CPU-bound ``Decimal`` work), or any executor
223
+ in tests. ``workers`` is the executor's worker count: paths are
224
+ split into that many contiguous chunks, so every submitted argument
225
+ tuple (plan, assumptions, region, config) is pickled once per
226
+ worker, not once per path. Paths are pure and order-independent
227
+ (planning §4.6), and chunk results are recombined in path order, so
228
+ the result is identical to a serial run whatever the executor.
229
+
230
+ The caller owns the executor's lifecycle: reuse one across the many
231
+ :func:`run_paths` calls of a retirement-age search rather than
232
+ paying process startup per candidate.
233
+ """
234
+
235
+ executor: Executor
236
+ workers: int
237
+
238
+ def __post_init__(self) -> None:
239
+ """Reject a worker count below one."""
240
+ if self.workers < 1:
241
+ msg = f"workers must be positive, got {self.workers}"
242
+ raise ValueError(msg)
243
+
244
+
245
+ def run_paths(
246
+ plan: Household,
247
+ assumptions: AssumptionSet,
248
+ region: Region,
249
+ config: RunConfig,
250
+ *,
251
+ paths: int,
252
+ parallelism: PathParallelism | None = None,
253
+ ) -> MonteCarloResult:
254
+ """Project ``plan`` over ``paths`` seeded paths (planning §5.2).
255
+
256
+ Path *i* is ``run(plan, assumptions, region, replace(config,
257
+ path=i))`` — always paths 0 through ``paths - 1``, whatever
258
+ ``config.path`` says: paths are order-independent and individually
259
+ re-runnable from the seed alone (planning §4.6). The provenance is
260
+ the union across paths in first-read order (class docstring).
261
+
262
+ With ``parallelism`` the paths run as contiguous chunks on its
263
+ executor; the result is identical to the serial run — same
264
+ outcomes in path order, same provenance union — because every path
265
+ is exactly determined by ``(config.seed, i)`` (§4.6).
266
+
267
+ Raises:
268
+ EngineError: If ``paths`` is not positive, ``config.mode`` is
269
+ not ``MONTE_CARLO``, the config carries no seed, or any
270
+ path's projection is rejected by the engine.
271
+ """
272
+ if paths < 1:
273
+ msg = f"paths must be positive, got {paths}"
274
+ raise EngineError(msg)
275
+ if config.mode is not RunMode.MONTE_CARLO:
276
+ msg = "run_paths requires RunMode.MONTE_CARLO (planning §5.2)"
277
+ raise EngineError(msg)
278
+ if parallelism is None or parallelism.workers == 1 or paths == 1:
279
+ outcomes, provenance = _run_path_range(
280
+ plan, assumptions, region, config, (0, paths)
281
+ )
282
+ else:
283
+ futures = [
284
+ parallelism.executor.submit(
285
+ _run_path_range, plan, assumptions, region, config, chunk
286
+ )
287
+ for chunk in _path_chunks(paths, parallelism.workers)
288
+ ]
289
+ chunk_results = [future.result() for future in futures]
290
+ outcomes = tuple(
291
+ outcome for chunk_outcomes, _ in chunk_results for outcome in chunk_outcomes
292
+ )
293
+ provenance = _merged_provenance(
294
+ [chunk_provenance for _, chunk_provenance in chunk_results]
295
+ )
296
+ return MonteCarloResult(outcomes=outcomes, config=config, provenance=provenance)
297
+
298
+
299
+ def _run_path_range(
300
+ plan: Household,
301
+ assumptions: AssumptionSet,
302
+ region: Region,
303
+ config: RunConfig,
304
+ chunk: tuple[int, int],
305
+ ) -> tuple[tuple[PathOutcome, ...], RunProvenance]:
306
+ """Project the ``(start, stop)`` half-open ``chunk`` of path indices.
307
+
308
+ The unit of work one executor worker runs: a module-level function
309
+ over picklable arguments, returning only the reduced outcomes and
310
+ the chunk's provenance union — never the paths' full ledgers, so a
311
+ parallel run ships back kilobytes per chunk, not the projections.
312
+ Merging the chunk unions in chunk order reproduces the serial
313
+ provenance exactly: the union operation is associative over ordered
314
+ concatenation, and this chunk's merged base is its first path's.
315
+ """
316
+ start, stop = chunk
317
+ outcomes: list[PathOutcome] = []
318
+ provenances: list[RunProvenance] = []
319
+ for index in range(start, stop):
320
+ result = run(plan, assumptions, region, _path_config(config, index))
321
+ outcomes.append(_path_outcome(index, result))
322
+ provenances.append(result.provenance)
323
+ return tuple(outcomes), _merged_provenance(provenances)
324
+
325
+
326
+ def _path_chunks(paths: int, workers: int) -> Iterator[tuple[int, int]]:
327
+ """Split ``range(paths)`` into up to ``workers`` contiguous chunks.
328
+
329
+ Chunk sizes differ by at most one, larger chunks first — a
330
+ deterministic tiling, so the parallel path order never depends on
331
+ scheduling.
332
+ """
333
+ chunk_count = min(workers, paths)
334
+ size, extra = divmod(paths, chunk_count)
335
+ start = 0
336
+ for index in range(chunk_count):
337
+ stop = start + size + (1 if index < extra else 0)
338
+ yield start, stop
339
+ start = stop
340
+
341
+
342
+ @dataclass(frozen=True, slots=True)
343
+ class SustainableIncomeSearch:
344
+ """The parameters of one sustainable-income search (planning §5.2).
345
+
346
+ The search covers ``[0, maximum]`` of real annual spending: a
347
+ descending scan over ``scan_steps`` equal steps finds the highest
348
+ succeeding scan point, then bisection refines upward within the
349
+ step above it, stopping once the bracket narrows to ``tolerance``.
350
+ The scan is what makes the search robust to a non-monotone success
351
+ predicate (module docstring): success islands narrower than
352
+ ``maximum / scan_steps`` can still be missed, so raise
353
+ ``scan_steps`` when the withdrawal strategy has adjustment
354
+ triggers; ``scan_steps=1`` is a pure bisection. Probe count is at
355
+ most ``1 + scan_steps`` plus the bisection's
356
+ ``log2(step / tolerance)``.
357
+
358
+ ``paths`` and ``target_success_rate`` apply only under a Monte
359
+ Carlo config: ``paths`` seeded paths are projected per candidate
360
+ spending level, and a candidate meets the target when at least the
361
+ target fraction of them avoid ruin. A deterministic probe ignores
362
+ them — success is one run with no unmet need, the single-path
363
+ equivalent of a 100% target (the same convention as
364
+ :class:`~glidepath.core.retirement.RetirementAgeSearch`).
365
+ """
366
+
367
+ maximum: Money
368
+ tolerance: Money = _DEFAULT_TOLERANCE
369
+ scan_steps: int = 10
370
+ paths: int = 1
371
+ target_success_rate: Decimal = _ONE
372
+
373
+ def __post_init__(self) -> None:
374
+ """Reject an empty path count, an off-range target, or bad bounds."""
375
+ if self.paths < 1:
376
+ msg = f"paths must be positive, got {self.paths}"
377
+ raise ValueError(msg)
378
+ if not Decimal(0) < self.target_success_rate <= _ONE:
379
+ msg = (
380
+ "target_success_rate must lie in (0, 1],"
381
+ f" got {self.target_success_rate}"
382
+ )
383
+ raise ValueError(msg)
384
+ if self.maximum <= _ZERO:
385
+ msg = "maximum must be positive"
386
+ raise ValueError(msg)
387
+ if self.tolerance <= _ZERO:
388
+ msg = "tolerance must be positive"
389
+ raise ValueError(msg)
390
+ if self.scan_steps < 1:
391
+ msg = f"scan_steps must be positive, got {self.scan_steps}"
392
+ raise ValueError(msg)
393
+
394
+
395
+ def sustainable_income(
396
+ plan: Household,
397
+ assumptions: AssumptionSet,
398
+ region: Region,
399
+ config: RunConfig,
400
+ search: SustainableIncomeSearch,
401
+ *,
402
+ parallelism: PathParallelism | None = None,
403
+ ) -> Money | None:
404
+ """The highest starting withdrawal meeting the target (scan + bisect).
405
+
406
+ Searches the spending plan's real annual amount (today's money —
407
+ the "starting withdrawal": the engine escalates it by the run's
408
+ CPI path) over the search's ``[0, maximum]``: a descending scan
409
+ over the search's ``scan_steps`` finds the highest succeeding scan
410
+ point, then bisection refines upward within the step above it.
411
+ Returns the highest probed level that met the target — ``maximum``
412
+ itself when even that meets — or ``None`` when no scan point and
413
+ not even zero spending does (the plan's outflows already exhaust
414
+ it). Every returned value was actually probed, never interpolated.
415
+
416
+ For a monotone success predicate (the default fixed-real strategy)
417
+ the result is within the search's tolerance of the true boundary;
418
+ for adjustment-trigger strategies it is never below the highest
419
+ succeeding scan point, with the resolution caveat on
420
+ :class:`SustainableIncomeSearch`.
421
+
422
+ The plan's stated spending amount is irrelevant to the search —
423
+ only its stage multipliers and the rest of the plan carry over; a
424
+ plan with no spending plan is probed with a bare one. Probes reuse
425
+ ``config`` unchanged, and its mode is the search's basis: under a
426
+ Monte Carlo config a candidate meets when its seeded paths' success
427
+ rate meets the search's target (same seed per probe: common random
428
+ numbers, keeping the §4.6 reproducibility guarantee over the whole
429
+ search); under a deterministic config a candidate meets when its
430
+ single run reports no period with unmet need — the §5.2 ruin
431
+ signal, exactly as :func:`~glidepath.core.retirement.earliest_retirement_age`
432
+ reads it. ``parallelism`` spreads each Monte Carlo probe's paths
433
+ over its executor (:class:`PathParallelism` — results identical to
434
+ a serial search); pass one executor for the whole search so probes
435
+ share it.
436
+
437
+ Raises:
438
+ EngineError: If a probe is rejected by the engine — including
439
+ a Monte Carlo config without a seed (planning §5.2).
440
+ """
441
+
442
+ def meets(amount: Money) -> bool:
443
+ """Whether spending ``amount`` meets the search's target."""
444
+ probe = probe_with_spending(plan, amount, config)
445
+ if config.mode is not RunMode.MONTE_CARLO:
446
+ return not has_shortfall(run(probe, assumptions, region, config))
447
+ result = run_paths(
448
+ probe,
449
+ assumptions,
450
+ region,
451
+ config,
452
+ paths=search.paths,
453
+ parallelism=parallelism,
454
+ )
455
+ return result.success_rate >= search.target_success_rate
456
+
457
+ high = search.maximum
458
+ if meets(high):
459
+ return high
460
+ low: Money | None = None
461
+ step = search.maximum.amount / Decimal(search.scan_steps)
462
+ for index in range(search.scan_steps - 1, 0, -1):
463
+ candidate = Money(step * Decimal(index))
464
+ if meets(candidate):
465
+ low = candidate
466
+ break
467
+ high = candidate
468
+ if low is None:
469
+ low = _ZERO
470
+ if not meets(low):
471
+ return None
472
+ while high - low > search.tolerance:
473
+ midpoint = Money((low.amount + high.amount) / _TWO)
474
+ if meets(midpoint):
475
+ low = midpoint
476
+ else:
477
+ high = midpoint
478
+ return low
479
+
480
+
481
+ def _check_percentile(percentile: Decimal) -> None:
482
+ """Reject a percentile outside [0, 100].
483
+
484
+ Raises:
485
+ ValueError: If ``percentile`` lies outside [0, 100].
486
+ """
487
+ if not Decimal(0) <= percentile <= _HUNDRED:
488
+ msg = f"percentile must lie between 0 and 100, got {percentile}"
489
+ raise ValueError(msg)
490
+
491
+
492
+ def _interpolated_percentile(balances: Sequence[Money], percentile: Decimal) -> Money:
493
+ """The ``percentile`` of ``balances`` between order statistics.
494
+
495
+ Rank ``(count - 1) * percentile / 100`` over the sorted balances,
496
+ fractional ranks interpolating between the two neighbours — exact
497
+ ``Decimal`` arithmetic, quantized as a presentation value
498
+ (planning §4.6).
499
+
500
+ Raises:
501
+ ValueError: If ``percentile`` lies outside [0, 100].
502
+ """
503
+ _check_percentile(percentile)
504
+ return _sorted_percentile(sorted(balances), percentile)
505
+
506
+
507
+ def _sorted_percentile(ordered: Sequence[Money], percentile: Decimal) -> Money:
508
+ """:func:`_interpolated_percentile` over an already-sorted vector.
509
+
510
+ Split out so a caller reading several percentiles of one
511
+ population (the fan chart's nine) sorts it once.
512
+ """
513
+ rank = (Decimal(len(ordered)) - _ONE) * percentile / _HUNDRED
514
+ lower = int(rank)
515
+ fraction = rank - Decimal(lower)
516
+ value = ordered[lower]
517
+ if fraction > 0:
518
+ value = value + (ordered[lower + 1] - ordered[lower]) * fraction
519
+ return value.quantized()
520
+
521
+
522
+ def _merged_provenance(provenances: Sequence[RunProvenance]) -> RunProvenance:
523
+ """Path 0's provenance with the assumption union of every path.
524
+
525
+ Almost every assumption read is plan- or date-driven and identical
526
+ across paths, but a balance-dependent read — natural-yield pricing
527
+ fires only while a source still holds money (roadmap 5.3) — can
528
+ appear on some paths only. The union keeps the §4.6 manifest
529
+ exhaustive: path 0's first-read order, then any later-path-only
530
+ keys in path order. Facts, decisions, the region data version, and
531
+ the seed are path-independent, so path 0's stand for all.
532
+ """
533
+ merged: dict[AssumptionKey, Assumption[Any]] = {}
534
+ for provenance in provenances:
535
+ for assumption in provenance.assumptions:
536
+ merged.setdefault(assumption.key, assumption)
537
+ changes: dict[str, Any] = {"assumptions": tuple(merged.values())}
538
+ return replace(provenances[0], **changes) if changes else provenances[0]
539
+
540
+
541
+ def _path_config(config: RunConfig, index: int) -> RunConfig:
542
+ """The run configuration for one path: ``config`` at ``path=index``."""
543
+ changes: dict[str, Any] = {"path": index}
544
+ return replace(config, **changes) if changes else config
545
+
546
+
547
+ def _path_outcome(index: int, result: ProjectionResult) -> PathOutcome:
548
+ """Reduce one path's projection to its success signals."""
549
+ first_shortfall = None
550
+ balances = []
551
+ for snapshot in result.snapshots:
552
+ shortfall = _ZERO
553
+ closing = _ZERO
554
+ for person in snapshot.persons:
555
+ shortfall = shortfall + person.shortfall
556
+ for wrapper in person.wrappers:
557
+ closing = closing + wrapper.closing_balance
558
+ if shortfall > _ZERO and first_shortfall is None:
559
+ first_shortfall = snapshot.period
560
+ balances.append(closing)
561
+ return PathOutcome(
562
+ path=index,
563
+ first_shortfall_period=first_shortfall,
564
+ ending_balance=balances[-1],
565
+ closing_balances=tuple(balances),
566
+ )
567
+
568
+
569
+ def has_shortfall(result: ProjectionResult) -> bool:
570
+ """Whether any period's need went unmet — the §5.2 ruin signal.
571
+
572
+ What a deterministic probe's success reads: the single-run
573
+ equivalent of a path's ruin. Shared by the sustainable-income
574
+ search and the earliest-retirement-age solver
575
+ (:mod:`glidepath.core.retirement`, roadmap 9.14); not part of the
576
+ package API.
577
+ """
578
+ return any(
579
+ person.shortfall > _ZERO
580
+ for snapshot in result.snapshots
581
+ for person in snapshot.persons
582
+ )
583
+
584
+
585
+ def probe_with_spending(plan: Household, amount: Money, config: RunConfig) -> Household:
586
+ """The plan with its spending level replaced by a probe amount.
587
+
588
+ An existing spending plan keeps its fact metadata and stage
589
+ multipliers; a plan without one gains a bare probe plan whose
590
+ "fact" is synthesized from ``config.today`` (no clock read —
591
+ planning §4.6). Probe plans never leave the search, so the
592
+ synthetic fact never lands in any result's provenance. Shared by
593
+ the sustainable-income search and the earliest-retirement-age
594
+ solver (:mod:`glidepath.core.retirement`, roadmap 9.14); not part
595
+ of the package API.
596
+ """
597
+ spending = plan.spending
598
+ if spending is None:
599
+ recorded = datetime.combine(config.today, time.min, tzinfo=UTC)
600
+ probe = SpendingPlan(
601
+ annual_spending_real=Fact(
602
+ value=amount, as_of=config.today, recorded_on=recorded
603
+ )
604
+ )
605
+ else:
606
+ fact = replace(spending.annual_spending_real, value=amount)
607
+ probe = replace(spending, annual_spending_real=fact)
608
+ changes: dict[str, Any] = {"spending": probe}
609
+ return replace(plan, **changes) if changes else plan