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,579 @@
1
+ """Scenario/Override model and resolution (roadmap 6.1; planning §4.3).
2
+
3
+ A scenario is a named list of typed :class:`Override` records over a base
4
+ plan — stored as deltas, so a scenario *is* its own diff and base-fact
5
+ corrections propagate to every what-if automatically. Targets are either
6
+ assumption keys or **decision variables** addressed by stable entity id +
7
+ field path, so overrides survive reordering and insertion.
8
+
9
+ The facts/decisions boundary is type-enforced (planning §4.3): the
10
+ field-path grammar names only decision variables — ``Decision[T]``
11
+ fields, plus the annuity purchase's plain enum choices (the record is
12
+ wholly a decision, §5.1) — and has no way to name a ``Fact``, so
13
+ nothing user-stated can be silently replaced. An override whose target
14
+ cannot be addressed in the plan (the entity is gone, the field path is
15
+ unknown, or the optional record it lives on is absent) is an *orphan*:
16
+ it flags the scenario invalid (:func:`scenario_orphans`) without
17
+ breaking anything else, until the user removes or retargets it.
18
+
19
+ Resolution (:func:`resolve_scenario`) computes effective inputs =
20
+ base ⊕ overrides: overridden assumptions carry ``SCENARIO_OVERRIDE``
21
+ provenance in-type; overridden decisions keep the base's ``recorded_on``
22
+ (resolution is pure — no clock reads, planning §4.6) and the resolution's
23
+ ``applied`` record lists every override at its stable label, which is
24
+ where a decision override's ``SCENARIO_OVERRIDE`` provenance lives.
25
+ """
26
+
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, replace
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from glidepath.core.provenance import (
32
+ Assumption,
33
+ AssumptionKey,
34
+ AssumptionSet,
35
+ Decision,
36
+ Provenance,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ from glidepath.core.annuities import AnnuityPurchase
41
+ from glidepath.core.entities import EntityId, Household, Person, PlannedOutflow
42
+ from glidepath.core.pensions import DBPension
43
+ from glidepath.core.wrappers import Wrapper
44
+
45
+
46
+ class ScenarioError(ValueError):
47
+ """A scenario that cannot be resolved against its base plan."""
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class AssumptionTarget:
52
+ """An override target naming an assumption by its stable key."""
53
+
54
+ key: AssumptionKey
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class DecisionTarget:
59
+ """An override target naming a decision variable (planning §4.3).
60
+
61
+ ``entity_id`` is the persisted id of the person, wrapper, DB
62
+ pension, annuity purchase, or planned outflow the decision lives
63
+ on; ``field_path`` is the dotted path of the ``Decision`` field on
64
+ that entity. The addressable paths are exactly the decision
65
+ whitelist:
66
+
67
+ - person: ``target_retirement_age``,
68
+ ``state_pension.deferral_years``
69
+ - wrapper: ``contributions.employee_amount``
70
+ - DB pension: ``taken_at_age``, ``commuted_fraction``,
71
+ ``active_membership.active_until_age``
72
+ - annuity purchase: ``at_age``, ``fraction_of_pot``,
73
+ ``annuity_type``, ``basis``
74
+ - planned outflow: ``amount_real``
75
+
76
+ Most paths name ``Decision[T]`` fields; the annuity purchase's
77
+ ``annuity_type`` and ``basis`` are plain enum fields, addressable
78
+ because the record is wholly a decision (planning §5.1).
79
+ """
80
+
81
+ entity_id: EntityId
82
+ field_path: str
83
+
84
+ def __post_init__(self) -> None:
85
+ """Reject an empty field path."""
86
+ if not self.field_path:
87
+ msg = "DecisionTarget.field_path must be non-empty"
88
+ raise ValueError(msg)
89
+
90
+
91
+ type OverrideTarget = AssumptionTarget | DecisionTarget
92
+ """What an override points at: an assumption key or a decision variable."""
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Override:
97
+ """One scenario delta: replace the target's value (planning §4.3).
98
+
99
+ ``value`` must have exactly the runtime type of the base value it
100
+ replaces — checked at resolution, so a drifted override fails
101
+ loudly rather than corrupting a run. ``note`` says why ("what if I
102
+ retire at 60") and becomes the resolved decision's note.
103
+ """
104
+
105
+ target: OverrideTarget
106
+ value: Any
107
+ note: str | None = None
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class Scenario:
112
+ """A named what-if: a list of overrides over the base plan (§4.3)."""
113
+
114
+ name: str
115
+ overrides: tuple[Override, ...] = ()
116
+ note: str | None = None
117
+
118
+ def __post_init__(self) -> None:
119
+ """Reject an empty name and ambiguous duplicate targets."""
120
+ if not self.name:
121
+ msg = "Scenario.name must be non-empty"
122
+ raise ValueError(msg)
123
+ targets = [override.target for override in self.overrides]
124
+ if len(set(targets)) != len(targets):
125
+ msg = f"scenario {self.name!r} has two overrides on one target"
126
+ raise ValueError(msg)
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class AppliedOverride:
131
+ """One override applied during resolution, at its stable label.
132
+
133
+ The label uses the provenance grammar of
134
+ :func:`~glidepath.core.results.collect_plan_decisions` (e.g.
135
+ ``person[<id>].target_retirement_age``) or the bare dotted
136
+ assumption key. Every applied override carries
137
+ ``SCENARIO_OVERRIDE`` provenance (planning §4.3) — for assumptions
138
+ it is also stamped in-type on the resolved
139
+ :class:`~glidepath.core.provenance.Assumption`.
140
+ """
141
+
142
+ label: str
143
+ override: Override
144
+
145
+ @property
146
+ def provenance(self) -> Provenance:
147
+ """Always ``SCENARIO_OVERRIDE`` (planning §4.3)."""
148
+ return Provenance.SCENARIO_OVERRIDE
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class ScenarioResolution:
153
+ """Effective inputs for one scenario: base ⊕ overrides (§4.3).
154
+
155
+ ``household`` and ``assumptions`` are the resolved inputs to hand
156
+ to the engine; ``applied`` lists every override at its stable
157
+ label, in the scenario's own order.
158
+ """
159
+
160
+ scenario: Scenario
161
+ household: Household
162
+ assumptions: AssumptionSet
163
+ applied: tuple[AppliedOverride, ...]
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class DecisionTargetInfo:
168
+ """One addressable decision target: its stable label and current value.
169
+
170
+ ``value`` is the decision's bare value (the ``Decision`` wrapper
171
+ unwrapped; the annuity purchase's enum choices are already bare) —
172
+ what an override on this target would replace. UIs list these to
173
+ offer exactly the decision whitelist (planning §4.3) without
174
+ re-deriving it.
175
+ """
176
+
177
+ target: DecisionTarget
178
+ label: str
179
+ value: Any
180
+
181
+
182
+ def decision_target_catalogue(household: Household) -> tuple[DecisionTargetInfo, ...]:
183
+ """Every addressable decision target, with its label and current value.
184
+
185
+ The same whitelist :func:`scenario_orphans` checks against, in the
186
+ same order — planned outflows first, then each person's targets.
187
+ """
188
+ entities = _entities_by_id(household)
189
+ infos = []
190
+ for (entity_id, field_path), label in _decision_target_labels(household).items():
191
+ value: Any = entities[entity_id]
192
+ for segment in field_path.split("."):
193
+ value = getattr(value, segment)
194
+ if isinstance(value, Decision):
195
+ value = value.value
196
+ infos.append(
197
+ DecisionTargetInfo(
198
+ target=DecisionTarget(entity_id=entity_id, field_path=field_path),
199
+ label=label,
200
+ value=value,
201
+ )
202
+ )
203
+ return tuple(infos)
204
+
205
+
206
+ def _entities_by_id(household: Household) -> dict[EntityId, Any]:
207
+ """Every override-addressable entity in the household, by id."""
208
+ entities: dict[EntityId, Any] = {
209
+ outflow.id: outflow for outflow in household.planned_outflows
210
+ }
211
+ for person in household.persons:
212
+ entities[person.id] = person
213
+ entities.update({wrapper.id: wrapper for wrapper in person.wrappers})
214
+ entities.update({pension.id: pension for pension in person.db_pensions})
215
+ entities.update(
216
+ {purchase.id: purchase for purchase in person.annuity_purchases}
217
+ )
218
+ return entities
219
+
220
+
221
+ def scenario_orphans(
222
+ scenario: Scenario, household: Household, assumptions: AssumptionSet
223
+ ) -> tuple[Override, ...]:
224
+ """The scenario's overrides whose targets cannot be addressed.
225
+
226
+ An orphan's target entity no longer exists, its field path is not
227
+ a whitelisted decision on that entity, the optional record it
228
+ lives on is absent (no state pension record, no contribution
229
+ schedule, no ``taken_at_age`` decision), or its assumption key has
230
+ no registered base assumption. Orphans flag the scenario invalid
231
+ without breaking file load (planning §4.3): callers surface them
232
+ for the user to remove or retarget.
233
+ """
234
+ decision_labels = _decision_target_labels(household)
235
+ orphans = []
236
+ for override in scenario.overrides:
237
+ target = override.target
238
+ if isinstance(target, AssumptionTarget):
239
+ if target.key not in assumptions:
240
+ orphans.append(override)
241
+ elif (target.entity_id, target.field_path) not in decision_labels:
242
+ orphans.append(override)
243
+ return tuple(orphans)
244
+
245
+
246
+ def is_scenario_valid(
247
+ scenario: Scenario, household: Household, assumptions: AssumptionSet
248
+ ) -> bool:
249
+ """Whether every override target is addressable (no orphans)."""
250
+ return not scenario_orphans(scenario, household, assumptions)
251
+
252
+
253
+ def resolve_scenario(
254
+ household: Household, assumptions: AssumptionSet, scenario: Scenario
255
+ ) -> ScenarioResolution:
256
+ """Compute the scenario's effective inputs: base ⊕ overrides (§4.3).
257
+
258
+ Pure (planning §4.6): resolved decisions keep the base value's
259
+ ``recorded_on``; resolved assumptions carry ``SCENARIO_OVERRIDE``
260
+ provenance with the base's default, source, and description intact.
261
+ The base household and assumption set are never mutated.
262
+
263
+ Raises:
264
+ ScenarioError: If any override is an orphan or its value's
265
+ runtime type differs from the base value it replaces.
266
+ ValueError: If an override value violates the target entity's
267
+ own invariants (e.g. a negative outflow amount).
268
+ """
269
+ orphans = scenario_orphans(scenario, household, assumptions)
270
+ if orphans:
271
+ labels = ", ".join(_target_description(o.target) for o in orphans)
272
+ msg = f"scenario {scenario.name!r} has orphaned overrides: {labels}"
273
+ raise ScenarioError(msg)
274
+ decision_labels = _decision_target_labels(household)
275
+ picks: dict[tuple[EntityId, str], Override] = {}
276
+ applied = []
277
+ for override in scenario.overrides:
278
+ target = override.target
279
+ if isinstance(target, AssumptionTarget):
280
+ applied.append(AppliedOverride(label=target.key.value, override=override))
281
+ else:
282
+ key = (target.entity_id, target.field_path)
283
+ picks[key] = override
284
+ applied.append(
285
+ AppliedOverride(label=decision_labels[key], override=override)
286
+ )
287
+ resolved_household = _apply_to_household(household, picks)
288
+ resolved_assumptions = _apply_to_assumptions(assumptions, scenario)
289
+ return ScenarioResolution(
290
+ scenario=scenario,
291
+ household=resolved_household,
292
+ assumptions=resolved_assumptions,
293
+ applied=tuple(applied),
294
+ )
295
+
296
+
297
+ def _target_description(target: OverrideTarget) -> str:
298
+ """A human-readable name for an override target in error messages."""
299
+ if isinstance(target, AssumptionTarget):
300
+ return target.key.value
301
+ return f"{target.entity_id}.{target.field_path}"
302
+
303
+
304
+ def _decision_target_labels(household: Household) -> dict[tuple[EntityId, str], str]:
305
+ """Every addressable decision target, mapped to its stable label.
306
+
307
+ The keys are ``(entity_id, field_path)`` pairs — entity ids are
308
+ unique across the household (enforced by ``Household``), so a pair
309
+ is unambiguous. Paths through absent optional records are simply
310
+ not addressable and never appear.
311
+ """
312
+ labels: dict[tuple[EntityId, str], str] = {}
313
+ for outflow in household.planned_outflows:
314
+ labels[(outflow.id, "amount_real")] = (
315
+ f"planned_outflow[{outflow.id}].amount_real"
316
+ )
317
+ for person in household.persons:
318
+ labels.update(_person_decision_target_labels(person))
319
+ return labels
320
+
321
+
322
+ def _person_decision_target_labels(person: Person) -> dict[tuple[EntityId, str], str]:
323
+ """One person's addressable decision targets, mapped to labels."""
324
+ labels: dict[tuple[EntityId, str], str] = {}
325
+ labels[(person.id, "target_retirement_age")] = (
326
+ f"person[{person.id}].target_retirement_age"
327
+ )
328
+ if person.state_pension is not None:
329
+ labels[(person.id, "state_pension.deferral_years")] = (
330
+ f"person[{person.id}].state_pension.deferral_years"
331
+ )
332
+ for wrapper in person.wrappers:
333
+ if wrapper.contributions is not None:
334
+ labels[(wrapper.id, "contributions.employee_amount")] = (
335
+ f"wrapper[{wrapper.id}].contributions.employee_amount"
336
+ )
337
+ for pension in person.db_pensions:
338
+ if pension.taken_at_age is not None:
339
+ labels[(pension.id, "taken_at_age")] = (
340
+ f"db_pension[{pension.id}].taken_at_age"
341
+ )
342
+ labels[(pension.id, "commuted_fraction")] = (
343
+ f"db_pension[{pension.id}].commuted_fraction"
344
+ )
345
+ if (
346
+ pension.active_membership is not None
347
+ and pension.active_membership.active_until_age is not None
348
+ ):
349
+ labels[(pension.id, "active_membership.active_until_age")] = (
350
+ f"db_pension[{pension.id}].active_membership.active_until_age"
351
+ )
352
+ for purchase in person.annuity_purchases:
353
+ for field in ("at_age", "fraction_of_pot", "annuity_type", "basis"):
354
+ labels[(purchase.id, field)] = f"annuity_purchase[{purchase.id}].{field}"
355
+ return labels
356
+
357
+
358
+ def _check_value_type(base_value: object, override_value: object, label: str) -> None:
359
+ """Reject an override value shaped unlike the base value it replaces.
360
+
361
+ Scalars must match the base's exact runtime type (``bool`` is an
362
+ ``int`` subtype but never a number here, §4.6 spirit). A mapping
363
+ base accepts *any* mapping: shipped structured defaults arrive as
364
+ read-only ``MappingProxyType`` views, while user overrides are
365
+ ordinarily plain dicts, and the structured-assumption parsers
366
+ accept either.
367
+
368
+ Raises:
369
+ ScenarioError: If the value's shape does not match.
370
+ """
371
+ if isinstance(base_value, Mapping):
372
+ if isinstance(override_value, Mapping):
373
+ return
374
+ msg = (
375
+ f"override on {label} must hold a mapping,"
376
+ f" got {type(override_value).__name__}"
377
+ )
378
+ raise ScenarioError(msg)
379
+ if type(override_value) is not type(base_value):
380
+ msg = (
381
+ f"override on {label} must hold a {type(base_value).__name__},"
382
+ f" got {type(override_value).__name__}"
383
+ )
384
+ raise ScenarioError(msg)
385
+
386
+
387
+ def _resolved_decision(
388
+ base: Decision[Any], override: Override, label: str
389
+ ) -> Decision[Any]:
390
+ """The decision with the override's value and note applied.
391
+
392
+ ``recorded_on`` stays the base's — resolution reads no clock
393
+ (planning §4.6); the override's own provenance lives in the
394
+ resolution's ``applied`` record.
395
+ """
396
+ _check_value_type(base.value, override.value, label)
397
+ return Decision(
398
+ value=override.value, recorded_on=base.recorded_on, note=override.note
399
+ )
400
+
401
+
402
+ def _apply_to_assumptions(
403
+ assumptions: AssumptionSet, scenario: Scenario
404
+ ) -> AssumptionSet:
405
+ """The assumption set with the scenario's assumption overrides applied."""
406
+ overrides = {
407
+ override.target.key: override
408
+ for override in scenario.overrides
409
+ if isinstance(override.target, AssumptionTarget)
410
+ }
411
+ if not overrides:
412
+ return assumptions
413
+ resolved = []
414
+ for key in assumptions.keys:
415
+ base = assumptions.get(key)
416
+ override = overrides.get(key)
417
+ if override is not None:
418
+ base = _overridden_assumption(base, override)
419
+ resolved.append(base)
420
+ return AssumptionSet(resolved)
421
+
422
+
423
+ def _overridden_assumption(
424
+ base: Assumption[Any], override: Override
425
+ ) -> Assumption[Any]:
426
+ """The assumption re-stamped with the override's value (§4.3).
427
+
428
+ The shipped default, source, and description survive so the UI can
429
+ still answer "what would this have been?"; provenance becomes
430
+ ``SCENARIO_OVERRIDE``.
431
+ """
432
+ _check_value_type(base.value, override.value, base.key.value)
433
+ return replace(base, value=override.value, provenance=Provenance.SCENARIO_OVERRIDE)
434
+
435
+
436
+ def _apply_to_household(
437
+ household: Household, picks: dict[tuple[EntityId, str], Override]
438
+ ) -> Household:
439
+ """The household with the picked decision overrides applied."""
440
+ changes: dict[str, Any] = {}
441
+ if picks:
442
+ changes["planned_outflows"] = tuple(
443
+ _apply_to_outflow(outflow, picks) for outflow in household.planned_outflows
444
+ )
445
+ changes["persons"] = tuple(
446
+ _apply_to_person(person, picks) for person in household.persons
447
+ )
448
+ return replace(household, **changes) if changes else household
449
+
450
+
451
+ def _apply_to_outflow(
452
+ outflow: PlannedOutflow, picks: dict[tuple[EntityId, str], Override]
453
+ ) -> PlannedOutflow:
454
+ """One planned outflow with its ``amount_real`` override applied."""
455
+ changes: dict[str, Any] = {}
456
+ override = picks.get((outflow.id, "amount_real"))
457
+ if override is not None:
458
+ label = f"planned_outflow[{outflow.id}].amount_real"
459
+ changes["amount_real"] = _resolved_decision(
460
+ outflow.amount_real, override, label
461
+ )
462
+ return replace(outflow, **changes) if changes else outflow
463
+
464
+
465
+ def _apply_to_person(
466
+ person: Person, picks: dict[tuple[EntityId, str], Override]
467
+ ) -> Person:
468
+ """One person with every decision override on them applied."""
469
+ changes: dict[str, Any] = {}
470
+ override = picks.get((person.id, "target_retirement_age"))
471
+ if override is not None:
472
+ changes["target_retirement_age"] = _resolved_decision(
473
+ person.target_retirement_age,
474
+ override,
475
+ f"person[{person.id}].target_retirement_age",
476
+ )
477
+ if person.state_pension is not None:
478
+ override = picks.get((person.id, "state_pension.deferral_years"))
479
+ if override is not None:
480
+ deferral = _resolved_decision(
481
+ person.state_pension.deferral_years,
482
+ override,
483
+ f"person[{person.id}].state_pension.deferral_years",
484
+ )
485
+ changes["state_pension"] = replace(
486
+ person.state_pension, deferral_years=deferral
487
+ )
488
+ wrappers = tuple(_apply_to_wrapper(wrapper, picks) for wrapper in person.wrappers)
489
+ if wrappers != person.wrappers:
490
+ changes["wrappers"] = wrappers
491
+ pensions = tuple(_apply_to_db_pension(p, picks) for p in person.db_pensions)
492
+ if pensions != person.db_pensions:
493
+ changes["db_pensions"] = pensions
494
+ purchases = tuple(
495
+ _apply_to_annuity_purchase(p, picks) for p in person.annuity_purchases
496
+ )
497
+ if purchases != person.annuity_purchases:
498
+ changes["annuity_purchases"] = purchases
499
+ return replace(person, **changes) if changes else person
500
+
501
+
502
+ def _apply_to_wrapper(
503
+ wrapper: Wrapper, picks: dict[tuple[EntityId, str], Override]
504
+ ) -> Wrapper:
505
+ """One wrapper with its employee-contribution override applied."""
506
+ changes: dict[str, Any] = {}
507
+ override = picks.get((wrapper.id, "contributions.employee_amount"))
508
+ if override is not None and wrapper.contributions is not None:
509
+ label = f"wrapper[{wrapper.id}].contributions.employee_amount"
510
+ changes["contributions"] = replace(
511
+ wrapper.contributions,
512
+ employee_amount=_resolved_decision(
513
+ wrapper.contributions.employee_amount, override, label
514
+ ),
515
+ )
516
+ return replace(wrapper, **changes) if changes else wrapper
517
+
518
+
519
+ def _apply_to_db_pension(
520
+ pension: DBPension, picks: dict[tuple[EntityId, str], Override]
521
+ ) -> DBPension:
522
+ """One DB pension with its decision overrides applied."""
523
+ changes: dict[str, Any] = {}
524
+ override = picks.get((pension.id, "taken_at_age"))
525
+ if override is not None and pension.taken_at_age is not None:
526
+ changes["taken_at_age"] = _resolved_decision(
527
+ pension.taken_at_age, override, f"db_pension[{pension.id}].taken_at_age"
528
+ )
529
+ override = picks.get((pension.id, "commuted_fraction"))
530
+ if override is not None:
531
+ changes["commuted_fraction"] = _resolved_decision(
532
+ pension.commuted_fraction,
533
+ override,
534
+ f"db_pension[{pension.id}].commuted_fraction",
535
+ )
536
+ override = picks.get((pension.id, "active_membership.active_until_age"))
537
+ membership = pension.active_membership
538
+ if (
539
+ override is not None
540
+ and membership is not None
541
+ and membership.active_until_age is not None
542
+ ):
543
+ changes["active_membership"] = replace(
544
+ membership,
545
+ active_until_age=_resolved_decision(
546
+ membership.active_until_age,
547
+ override,
548
+ f"db_pension[{pension.id}].active_membership.active_until_age",
549
+ ),
550
+ )
551
+ return replace(pension, **changes) if changes else pension
552
+
553
+
554
+ def _apply_to_annuity_purchase(
555
+ purchase: AnnuityPurchase, picks: dict[tuple[EntityId, str], Override]
556
+ ) -> AnnuityPurchase:
557
+ """One annuity purchase with its decision overrides applied.
558
+
559
+ ``annuity_type`` and ``basis`` are plain enum fields — the whole
560
+ record is a decision (planning §5.1) — so their overrides replace
561
+ the value directly; the override's note lives only in the
562
+ resolution's ``applied`` record.
563
+ """
564
+ changes: dict[str, Any] = {}
565
+ for field in ("at_age", "fraction_of_pot"):
566
+ override = picks.get((purchase.id, field))
567
+ if override is not None:
568
+ changes[field] = _resolved_decision(
569
+ getattr(purchase, field),
570
+ override,
571
+ f"annuity_purchase[{purchase.id}].{field}",
572
+ )
573
+ for field in ("annuity_type", "basis"):
574
+ override = picks.get((purchase.id, field))
575
+ if override is not None:
576
+ label = f"annuity_purchase[{purchase.id}].{field}"
577
+ _check_value_type(getattr(purchase, field), override.value, label)
578
+ changes[field] = override.value
579
+ return replace(purchase, **changes) if changes else purchase