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,504 @@
1
+ """Projection results, snapshots, and run provenance (roadmap 4.1; planning §5.2).
2
+
3
+ The engine emits one :class:`PeriodSnapshot` per period — balances,
4
+ flows by category, tax breakdown, ages, stage, and allocation per
5
+ person and wrapper (planning §5.2 step 8) — and returns them in a
6
+ :class:`ProjectionResult` whose :class:`RunProvenance` lists the facts
7
+ used, the assumptions actually read (default vs overridden), the
8
+ decision variables in effect, the region data version, and the seed:
9
+ exactly the payload the UI's "stated vs assumed" inspector renders
10
+ (planning §5.1).
11
+
12
+ All monetary snapshot fields are quantized ledger writes (planning
13
+ §4.6): the engine rounds at period close, so consumers never see
14
+ sub-penny amounts.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from decimal import Decimal
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from glidepath.core.money import Money
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+ from datetime import date
26
+
27
+ from glidepath.core.config import RunConfig
28
+ from glidepath.core.entities import EntityId, Household
29
+ from glidepath.core.glide import LifeStage
30
+ from glidepath.core.investments import AssetAllocation
31
+ from glidepath.core.pensions import DBPension
32
+ from glidepath.core.periods import Period
33
+ from glidepath.core.provenance import Assumption, Decision, Fact
34
+ from glidepath.core.returns import PeriodReturns
35
+ from glidepath.core.tax import TaxResult
36
+ from glidepath.core.wrappers import WrapperKindId
37
+
38
+ _ZERO = Money(Decimal(0))
39
+ _ZERO_FACTOR = Decimal(0)
40
+ _ONE_FACTOR = Decimal(1)
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class WrapperPeriodResult:
45
+ """One wrapper's balances and flows through one period (§5.2 step 8).
46
+
47
+ Pension sub-balances are tracked separately: ``uncrystallised``
48
+ funds have not been accessed; ``crystallised`` funds are already in
49
+ drawdown (planning §5.1). Non-pension kinds keep the crystallised
50
+ fields at zero. ``employee_contribution`` is the gross amount that
51
+ landed in the pot (of which ``provider_relief`` arrived from the
52
+ provider's at-source reclaim); ``contribution_bonus`` is a
53
+ government bonus credited on top of it (the UK LISA's 25%, roadmap
54
+ 9.2); ``contribution_shortfall`` is the intended amount that could
55
+ not be contributed (per-kind caps or the region's relief limits).
56
+ ``annuity_purchase`` is capital that left the wrapper to buy
57
+ annuity income this period (roadmap 5.5) — the purchase's tax-free
58
+ cash element is paid out through ``withdrawal_tax_free`` instead,
59
+ exactly like an up-front lump sum.
60
+
61
+ Taxable-growth wrappers (roadmap 9.2): ``taxable_interest`` and
62
+ ``taxable_dividends`` are the period's portfolio income entering
63
+ the tax assessment's savings and dividend layers; ``growth_tax``
64
+ is the tax attributable to that income actually charged to the
65
+ wrapper at period close — capped at what the account then holds,
66
+ with any unfunded remainder joining the person's shortfall
67
+ (planning §5.2). ``aa_charge`` is the slice of the period's priced
68
+ annual-allowance charge this wrapper actually funded at close
69
+ (#124) — a scheme-pays debit on a pension wrapper, or the cash
70
+ route on a bare taxable one — under the same close-settlement
71
+ convention as ``growth_tax``; it is not a withdrawal and appears
72
+ in no ``withdrawal_*`` flow. ``banked_in`` is decumulation surplus
73
+ swept into this wrapper — income or gross draws beyond the
74
+ period's need (planning §5.2). ``growth`` may be negative (a down
75
+ period); every other flow is non-negative.
76
+ """
77
+
78
+ wrapper_id: EntityId
79
+ kind: WrapperKindId
80
+ allocation: AssetAllocation
81
+ opening_uncrystallised: Money
82
+ opening_crystallised: Money
83
+ employee_contribution: Money
84
+ employer_contribution: Money
85
+ provider_relief: Money
86
+ contribution_shortfall: Money
87
+ withdrawal_tax_free: Money
88
+ withdrawal_taxable: Money
89
+ fee: Money
90
+ growth: Money
91
+ closing_uncrystallised: Money
92
+ closing_crystallised: Money
93
+ annuity_purchase: Money = _ZERO
94
+ contribution_bonus: Money = _ZERO
95
+ taxable_interest: Money = _ZERO
96
+ taxable_dividends: Money = _ZERO
97
+ growth_tax: Money = _ZERO
98
+ aa_charge: Money = _ZERO
99
+ banked_in: Money = _ZERO
100
+
101
+ def __post_init__(self) -> None:
102
+ """Reject negative amounts in the non-negative fields."""
103
+ non_negative = (
104
+ self.opening_uncrystallised,
105
+ self.opening_crystallised,
106
+ self.employee_contribution,
107
+ self.employer_contribution,
108
+ self.provider_relief,
109
+ self.contribution_shortfall,
110
+ self.withdrawal_tax_free,
111
+ self.withdrawal_taxable,
112
+ self.fee,
113
+ self.closing_uncrystallised,
114
+ self.closing_crystallised,
115
+ self.annuity_purchase,
116
+ self.contribution_bonus,
117
+ self.taxable_interest,
118
+ self.taxable_dividends,
119
+ self.growth_tax,
120
+ self.aa_charge,
121
+ self.banked_in,
122
+ )
123
+ if any(amount < _ZERO for amount in non_negative):
124
+ msg = "WrapperPeriodResult amounts (except growth) must be non-negative"
125
+ raise ValueError(msg)
126
+
127
+ @property
128
+ def opening_balance(self) -> Money:
129
+ """Both sub-balances at period open."""
130
+ return self.opening_uncrystallised + self.opening_crystallised
131
+
132
+ @property
133
+ def closing_balance(self) -> Money:
134
+ """Both sub-balances at period close."""
135
+ return self.closing_uncrystallised + self.closing_crystallised
136
+
137
+ @property
138
+ def withdrawal_gross(self) -> Money:
139
+ """The period's total gross withdrawal from this wrapper."""
140
+ return self.withdrawal_tax_free + self.withdrawal_taxable
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class PersonPeriodResult:
145
+ """One person's position through one period (§5.2 step 8).
146
+
147
+ ``spending_need`` is the period's net (after-tax) spending target in
148
+ nominal money — zero before decumulation; ``net_withdrawn`` is the
149
+ net cash the withdrawal step delivered toward it; ``shortfall`` is
150
+ the need left unmet after the configured withdrawal strategy's plan
151
+ executed, plus any portfolio-income tax a drained taxable wrapper
152
+ could not fund (roadmap 9.2) and any slice of the annual-allowance
153
+ charge no wrapper could fund (#124) — the ruin signal the success
154
+ metrics of roadmap 7.3 read.
155
+ Under the default net-defined strategy a shortfall means every
156
+ accessible wrapper was exhausted; a gross-defined strategy (e.g.
157
+ fixed-%) may report one with balances still standing, because its
158
+ draw follows the pot, not the need (planning §5.2).
159
+
160
+ ``db_income`` and ``state_pension_income`` are the period's DB and
161
+ state pension income actually in payment (revalued/uprated,
162
+ pro-rated from their exact start dates, §4.1); ``db_lump_sum`` is
163
+ the gross commutation cash received when a DB pension starts this
164
+ period (roadmap 4.2/4.3) — tax-free up to the remaining lump-sum
165
+ allowance, the excess taxed as income (roadmap 5.2).
166
+ ``annuity_income`` is purchased annuity income in payment
167
+ (escalated per its type, pro-rated from its exact start date), and
168
+ ``annuity_lump_sum`` is the tax-free cash delivered alongside an
169
+ annuity purchase's crystallisation of uncrystallised funds this
170
+ period, capped by the remaining lump-sum allowance headroom
171
+ (roadmap 5.5).
172
+ ``planned_outflows`` is the nominal total
173
+ of the household's dated one-offs landing this period (roadmap
174
+ 5.4) — a net need on top of ``spending_need``, so the shortfall
175
+ accounting covers both.
176
+
177
+ Tax-free cash tracking (roadmap 5.2): ``pension_lump_sum`` is
178
+ up-front tax-free cash delivered by a whole-pot crystallisation
179
+ this period (the ``UP_FRONT_LUMP_SUM`` event). Like
180
+ ``annuity_lump_sum``, it is a column view of cash the paying
181
+ wrapper also records in ``withdrawal_tax_free`` — and so inside
182
+ its gross withdrawals — never an addition to them: summing either
183
+ with the wrapper withdrawals double-counts the tax-free cash.
184
+ Phased (as-needed or split-payment) tax-free elements appear only
185
+ in ``withdrawal_tax_free``.
186
+ ``lsa_used`` is the person's cumulative tax-free cash at period
187
+ end, including the pre-plan ``lsa_used`` fact;
188
+ ``mpaa_triggered_on`` is the flexible-access trigger date in
189
+ effect at period end — the pre-plan fact or the in-run first
190
+ taxable pension draw — or ``None`` if never triggered.
191
+
192
+ ``banked`` is the period's surplus swept into a taxable wrapper
193
+ (roadmap 9.2): in decumulation, income and gross draws beyond the
194
+ net need; before retirement, non-employment income already in
195
+ payment (an early DB start, a purchased annuity, the state
196
+ pension alongside work) net of its marginal tax and beyond the
197
+ period's planned outflows. The sweep lands in the first uncapped
198
+ taxable account rather than being spent — zero when the person
199
+ holds none (planning §5.2).
200
+ """
201
+
202
+ person_id: EntityId
203
+ age_at_period_start: int
204
+ years_to_retirement: int
205
+ stage: LifeStage
206
+ employment_income: Money
207
+ tax: TaxResult
208
+ spending_need: Money
209
+ net_withdrawn: Money
210
+ shortfall: Money
211
+ wrappers: tuple[WrapperPeriodResult, ...]
212
+ db_income: Money = _ZERO
213
+ db_lump_sum: Money = _ZERO
214
+ state_pension_income: Money = _ZERO
215
+ annuity_income: Money = _ZERO
216
+ annuity_lump_sum: Money = _ZERO
217
+ planned_outflows: Money = _ZERO
218
+ pension_lump_sum: Money = _ZERO
219
+ lsa_used: Money = _ZERO
220
+ mpaa_triggered_on: date | None = None
221
+ banked: Money = _ZERO
222
+
223
+ def __post_init__(self) -> None:
224
+ """Reject negative flows."""
225
+ amounts = (
226
+ self.employment_income,
227
+ self.spending_need,
228
+ self.net_withdrawn,
229
+ self.shortfall,
230
+ self.db_income,
231
+ self.db_lump_sum,
232
+ self.state_pension_income,
233
+ self.annuity_income,
234
+ self.annuity_lump_sum,
235
+ self.planned_outflows,
236
+ self.pension_lump_sum,
237
+ self.lsa_used,
238
+ self.banked,
239
+ )
240
+ if any(amount < _ZERO for amount in amounts):
241
+ msg = "PersonPeriodResult amounts must be non-negative"
242
+ raise ValueError(msg)
243
+
244
+
245
+ @dataclass(frozen=True, slots=True)
246
+ class PeriodSnapshot:
247
+ """The full ledger record of one projected period (§5.2 step 8).
248
+
249
+ ``inflation_factor`` is the cumulative factor from the run's first
250
+ period to this one — the CPI path the engine inflated nominal
251
+ figures by, which the reporting layer deflates by (roadmap 4.4:
252
+ one inflation truth per run). The first period's factor is 1.
253
+
254
+ ``year_fraction`` is the whole-month fraction of the period inside
255
+ the run window (roadmap 4.6, planning §5.2): 1 for a whole period;
256
+ less when ``today`` or the horizon end falls mid-period, in which
257
+ case the period's flows, fees, and growth were scaled by it.
258
+ """
259
+
260
+ period: Period
261
+ returns: PeriodReturns
262
+ inflation_factor: Decimal
263
+ persons: tuple[PersonPeriodResult, ...]
264
+ year_fraction: Decimal = _ONE_FACTOR
265
+
266
+ def __post_init__(self) -> None:
267
+ """Require a positive inflation factor and a fraction in [0, 1]."""
268
+ if self.inflation_factor <= _ZERO_FACTOR:
269
+ msg = "PeriodSnapshot.inflation_factor must be positive"
270
+ raise ValueError(msg)
271
+ if not _ZERO_FACTOR <= self.year_fraction <= _ONE_FACTOR:
272
+ msg = "PeriodSnapshot.year_fraction must lie between 0 and 1"
273
+ raise ValueError(msg)
274
+
275
+
276
+ @dataclass(frozen=True, slots=True)
277
+ class LabelledFact:
278
+ """One user-stated fact the run used, at a stable plan path."""
279
+
280
+ label: str
281
+ fact: Fact[Any]
282
+
283
+
284
+ @dataclass(frozen=True, slots=True)
285
+ class BalanceRollForward:
286
+ """One stale stated amount rolled forward to the run start (§4.8).
287
+
288
+ A statement-dated fact — a wrapper balance, or the DWP forecast's
289
+ weekly rates — is dated ``as_of``; the engine opens the run with
290
+ ``stated`` compounded over the ``months`` whole months from
291
+ ``as_of`` to the run's ``today`` at the rate that governs it (the
292
+ wrapper's expected nominal return; the state pension uprating
293
+ rule, CPI-only for the protected slice) — ``factor`` is the exact
294
+ multiplier applied and ``opening`` the quantized result. The stated
295
+ fact itself is never altered: this record is how the estimate
296
+ layered on it stays visible rather than silent (planning §4.8).
297
+ ``label`` addresses the fact at its stable plan path, exactly like
298
+ :class:`LabelledFact`.
299
+ """
300
+
301
+ label: str
302
+ stated: Money
303
+ as_of: date
304
+ months: int
305
+ factor: Decimal
306
+ opening: Money
307
+
308
+ def __post_init__(self) -> None:
309
+ """Reject records that could not describe a §4.8 roll-forward."""
310
+ if self.months <= 0:
311
+ msg = "BalanceRollForward.months must be positive"
312
+ raise ValueError(msg)
313
+ if self.factor <= _ZERO_FACTOR:
314
+ msg = "BalanceRollForward.factor must be positive"
315
+ raise ValueError(msg)
316
+
317
+
318
+ @dataclass(frozen=True, slots=True)
319
+ class LabelledDecision:
320
+ """One user choice in effect during the run, at a stable plan path."""
321
+
322
+ label: str
323
+ decision: Decision[Any]
324
+
325
+
326
+ @dataclass(frozen=True, slots=True)
327
+ class RunProvenance:
328
+ """What a run's numbers rest on (planning §5.1, §4.6).
329
+
330
+ ``assumptions`` lists every assumption actually read, in first-read
331
+ order, each carrying its own default-vs-overridden provenance —
332
+ the engine-side read tracking makes this exhaustive with no UI
333
+ bookkeeping (planning §5.1).
334
+
335
+ ``balance_roll_forwards`` lists every statement-dated fact the run
336
+ rolled forward from its ``as_of`` to ``today`` (planning §4.8) —
337
+ wrapper balances and the state pension forecast's weekly rates —
338
+ empty when every such fact was stated within a whole month of the
339
+ run start.
340
+ """
341
+
342
+ facts: tuple[LabelledFact, ...]
343
+ decisions: tuple[LabelledDecision, ...]
344
+ assumptions: tuple[Assumption[Any], ...]
345
+ region_data_version: str
346
+ seed: int | None
347
+ balance_roll_forwards: tuple[BalanceRollForward, ...] = ()
348
+
349
+
350
+ @dataclass(frozen=True, slots=True)
351
+ class ProjectionResult:
352
+ """One deterministic projection: the period ledger plus provenance.
353
+
354
+ ``config`` is the exact :class:`~glidepath.core.RunConfig` the run
355
+ received — today, horizon, mode, seed — so a result carries the
356
+ configuration part of its §4.6 manifest (the full persisted
357
+ manifest is Phase 6 work).
358
+ """
359
+
360
+ snapshots: tuple[PeriodSnapshot, ...]
361
+ provenance: RunProvenance
362
+ config: RunConfig
363
+
364
+
365
+ def _note_db_pension_facts(
366
+ pension: DBPension, note: Callable[[str, Fact[Any] | None], None]
367
+ ) -> None:
368
+ """One DB pension's facts under its stable entity-id prefix (§5.1)."""
369
+ prefix = f"db_pension[{pension.id}]"
370
+ note(f"{prefix}.accrued_annual_pension", pension.accrued_annual_pension)
371
+ note(f"{prefix}.normal_pension_age", pension.normal_pension_age)
372
+ note(f"{prefix}.commutation_factor", pension.commutation_factor)
373
+ membership = pension.active_membership
374
+ if membership is not None:
375
+ note(f"{prefix}.active_membership.accrual_rate", membership.accrual_rate)
376
+ note(
377
+ f"{prefix}.active_membership.pensionable_salary",
378
+ membership.pensionable_salary,
379
+ )
380
+
381
+
382
+ def collect_plan_facts(household: Household) -> tuple[LabelledFact, ...]:
383
+ """Every user-stated fact in the plan, at stable entity-id paths.
384
+
385
+ The engine's inputs are total — a projection reads the whole plan —
386
+ so the facts used are the facts present (planning §5.1).
387
+ """
388
+ facts: list[LabelledFact] = []
389
+
390
+ def note(label: str, fact: Fact[Any] | None) -> None:
391
+ """Record ``fact`` under ``label`` when present."""
392
+ if fact is not None:
393
+ facts.append(LabelledFact(label=label, fact=fact))
394
+
395
+ if household.spending is not None:
396
+ note(
397
+ "household.spending.annual_spending_real",
398
+ household.spending.annual_spending_real,
399
+ )
400
+ for person in household.persons:
401
+ prefix = f"person[{person.id}]"
402
+ note(f"{prefix}.date_of_birth", person.date_of_birth)
403
+ note(f"{prefix}.sex_for_longevity", person.sex_for_longevity)
404
+ note(f"{prefix}.employment_income", person.employment_income)
405
+ note(f"{prefix}.mpaa_triggered_on", person.mpaa_triggered_on)
406
+ note(f"{prefix}.lsa_used", person.lsa_used)
407
+ for pension in person.db_pensions:
408
+ _note_db_pension_facts(pension, note)
409
+ if person.state_pension is not None:
410
+ record = person.state_pension
411
+ record_prefix = f"{prefix}.state_pension"
412
+ note(
413
+ f"{record_prefix}.forecast_weekly_amount",
414
+ record.forecast_weekly_amount,
415
+ )
416
+ note(f"{record_prefix}.protected_payment", record.protected_payment)
417
+ for wrapper in person.wrappers:
418
+ wrapper_prefix = f"wrapper[{wrapper.id}]"
419
+ note(f"{wrapper_prefix}.balance", wrapper.balance)
420
+ note(f"{wrapper_prefix}.crystallised_balance", wrapper.crystallised_balance)
421
+ if wrapper.contributions is not None:
422
+ note(
423
+ f"{wrapper_prefix}.contributions.employer_amount",
424
+ wrapper.contributions.employer_amount,
425
+ )
426
+ return tuple(facts)
427
+
428
+
429
+ def collect_plan_decisions(household: Household) -> tuple[LabelledDecision, ...]:
430
+ """Every decision variable in effect, at stable entity-id paths.
431
+
432
+ Decisions are exactly the scenario what-if whitelist (planning
433
+ §4.3): retirement ages and contribution choices today; withdrawal
434
+ and annuity choices as later phases add them.
435
+ """
436
+ decisions: list[LabelledDecision] = []
437
+ decisions.extend(
438
+ LabelledDecision(
439
+ label=f"planned_outflow[{outflow.id}].amount_real",
440
+ decision=outflow.amount_real,
441
+ )
442
+ for outflow in household.planned_outflows
443
+ )
444
+ for person in household.persons:
445
+ decisions.append(
446
+ LabelledDecision(
447
+ label=f"person[{person.id}].target_retirement_age",
448
+ decision=person.target_retirement_age,
449
+ )
450
+ )
451
+ for pension in person.db_pensions:
452
+ pension_prefix = f"db_pension[{pension.id}]"
453
+ if pension.taken_at_age is not None:
454
+ decisions.append(
455
+ LabelledDecision(
456
+ label=f"{pension_prefix}.taken_at_age",
457
+ decision=pension.taken_at_age,
458
+ )
459
+ )
460
+ decisions.append(
461
+ LabelledDecision(
462
+ label=f"{pension_prefix}.commuted_fraction",
463
+ decision=pension.commuted_fraction,
464
+ )
465
+ )
466
+ if (
467
+ pension.active_membership is not None
468
+ and pension.active_membership.active_until_age is not None
469
+ ):
470
+ decisions.append(
471
+ LabelledDecision(
472
+ label=f"{pension_prefix}.active_membership.active_until_age",
473
+ decision=pension.active_membership.active_until_age,
474
+ )
475
+ )
476
+ for purchase in person.annuity_purchases:
477
+ purchase_prefix = f"annuity_purchase[{purchase.id}]"
478
+ decisions.append(
479
+ LabelledDecision(
480
+ label=f"{purchase_prefix}.at_age", decision=purchase.at_age
481
+ )
482
+ )
483
+ decisions.append(
484
+ LabelledDecision(
485
+ label=f"{purchase_prefix}.fraction_of_pot",
486
+ decision=purchase.fraction_of_pot,
487
+ )
488
+ )
489
+ if person.state_pension is not None:
490
+ decisions.append(
491
+ LabelledDecision(
492
+ label=f"person[{person.id}].state_pension.deferral_years",
493
+ decision=person.state_pension.deferral_years,
494
+ )
495
+ )
496
+ decisions.extend(
497
+ LabelledDecision(
498
+ label=f"wrapper[{wrapper.id}].contributions.employee_amount",
499
+ decision=wrapper.contributions.employee_amount,
500
+ )
501
+ for wrapper in person.wrappers
502
+ if wrapper.contributions is not None
503
+ )
504
+ return tuple(decisions)