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,506 @@
1
+ """The stated-vs-assumed inspector (roadmap 8.3; planning §1, §5.1).
2
+
3
+ Renders ``ProjectionResult.provenance`` — the engine's own record of
4
+ what the run rested on — as three columns: facts the user stated,
5
+ assumptions used (default vs overridden, with source and date), and
6
+ decisions in effect. The full shipped assumption catalogue follows the
7
+ read list so every default is overridable in place even before it is
8
+ read by a run. A fourth, entity-level section surfaces the plan's
9
+ *structural* inputs — fields that drive results but are neither
10
+ ``Fact`` nor ``Decision`` wrapped, so they never appear as provenance
11
+ rows (planning §5.1, issue #70).
12
+ """
13
+
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from glidepath.app.display import (
19
+ format_assumption_key,
20
+ format_date,
21
+ format_money,
22
+ format_recorded,
23
+ format_value,
24
+ format_wrapper_kind,
25
+ )
26
+ from glidepath.app.labels import display_label, entity_names
27
+ from glidepath.app.tables import table_edit_text
28
+ from glidepath.core import (
29
+ Assumption,
30
+ AssumptionKey,
31
+ Household,
32
+ Provenance,
33
+ RevaluationReference,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from decimal import Decimal
38
+
39
+ from glidepath.app.plan import PlanState
40
+ from glidepath.core import (
41
+ AnnuityPurchase,
42
+ AssetAllocation,
43
+ DBPension,
44
+ FactorTable,
45
+ FeeSchedule,
46
+ GlidePathConfig,
47
+ LifeStage,
48
+ Person,
49
+ RevaluationBasis,
50
+ Wrapper,
51
+ )
52
+
53
+ _STATUS_LABELS: Mapping[Provenance, str] = {
54
+ Provenance.DEFAULT_ASSUMPTION: "Shipped default",
55
+ Provenance.USER_OVERRIDE: "Your override",
56
+ Provenance.SCENARIO_OVERRIDE: "Scenario override",
57
+ }
58
+
59
+ _RESIDENCY_NAMES: Mapping[str, str] = {
60
+ "uk.ruk": "UK (England, Wales, or Northern Ireland)",
61
+ "uk.scotland": "UK (Scotland)",
62
+ }
63
+
64
+ _USED_LABEL = "Used"
65
+ _UNUSED_LABEL = "Not used"
66
+ _NO_RUN_LABEL = "No projection yet"
67
+ _REGION_BUILD_LABEL = "Applied at region build"
68
+
69
+ _REGION_BUILD_KEYS = frozenset(
70
+ {
71
+ AssumptionKey.POLICY_STATE_PENSION_UPRATING,
72
+ AssumptionKey.POLICY_TAX_FUTURE_YEARS,
73
+ }
74
+ )
75
+ """Keys read while building the region, before the run's read recorder
76
+ exists — their effect is identified through the region data version,
77
+ not the assumption read list (planning §5.1)."""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class FactRow:
82
+ """One stated fact, formatted for display."""
83
+
84
+ label: str
85
+ value: str
86
+ as_of: str
87
+ recorded: str
88
+ note: str
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class RollForwardRow:
93
+ """One stated amount rolled forward to the run start (§4.8).
94
+
95
+ A wrapper balance or state pension forecast rate: the stated fact
96
+ is unchanged; this row shows the estimate the engine layered on it
97
+ — the statement value, its date, the whole months rolled, and the
98
+ opening value the projection actually used.
99
+ """
100
+
101
+ label: str
102
+ stated: str
103
+ as_of: str
104
+ months: str
105
+ opening: str
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class DecisionRow:
110
+ """One decision in effect, formatted for display."""
111
+
112
+ label: str
113
+ value: str
114
+ recorded: str
115
+ note: str
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class AssumptionRow:
120
+ """One assumption with its full §1 provenance payload.
121
+
122
+ ``key`` is the stable dotted id (what overrides target); ``label``
123
+ is its human display name and is what screens show. ``structured``
124
+ marks a table-valued row; its ``edit_text`` is the round-trippable
125
+ multi-line text form, while a scalar's is simply its value
126
+ (issue #71). ``value`` and ``default_value`` are display text and
127
+ truncate long tables; ``default_edit_text`` is the default's
128
+ complete form (a dash when nothing is overridden), so an export
129
+ can print the full figures the screen elides (9.19).
130
+ """
131
+
132
+ key: str
133
+ label: str
134
+ description: str
135
+ value: str
136
+ default_value: str
137
+ default_edit_text: str
138
+ status: str
139
+ usage: str
140
+ source: str
141
+ recorded: str
142
+ structured: bool
143
+ edit_text: str
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class StructureRow:
148
+ """One structural plan input, surfaced entity-level (§5.1, issue #70)."""
149
+
150
+ entity: str
151
+ setting: str
152
+ value: str
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class InspectorViewModel:
157
+ """The whole stated-vs-assumed screen (roadmap 8.3)."""
158
+
159
+ title: str
160
+ facts_heading: str
161
+ facts_columns: tuple[str, ...]
162
+ facts: tuple[FactRow, ...]
163
+ roll_forwards_heading: str
164
+ roll_forwards_columns: tuple[str, ...]
165
+ roll_forwards: tuple[RollForwardRow, ...]
166
+ assumptions_heading: str
167
+ assumptions_columns: tuple[str, ...]
168
+ assumptions: tuple[AssumptionRow, ...]
169
+ decisions_heading: str
170
+ decisions_columns: tuple[str, ...]
171
+ decisions: tuple[DecisionRow, ...]
172
+ structure_heading: str
173
+ structure_columns: tuple[str, ...]
174
+ structure: tuple[StructureRow, ...]
175
+ summary: str
176
+ summary_detail: str
177
+ override_title: str
178
+ override_prompt: str
179
+ table_override_prompt: str
180
+
181
+
182
+ def _assumption_row(assumption: Assumption[Any], usage: str) -> AssumptionRow:
183
+ """One assumption as its display row.
184
+
185
+ The default column repeats the value verbatim whenever nothing is
186
+ overridden (the status column already says "Shipped default"), so
187
+ it renders as a dash unless the two differ — compared on the
188
+ underlying values, since long table values format to a truncated
189
+ text that can collide.
190
+ """
191
+ structured = isinstance(assumption.value, Mapping)
192
+ value_text = format_value(assumption.value)
193
+ overridden = assumption.value != assumption.default_value
194
+ default_edit_text = "—"
195
+ if overridden:
196
+ default_edit_text = (
197
+ table_edit_text(assumption.default_value)
198
+ if isinstance(assumption.default_value, Mapping)
199
+ else format_value(assumption.default_value)
200
+ )
201
+ return AssumptionRow(
202
+ key=str(assumption.key.value),
203
+ label=format_assumption_key(assumption.key.value),
204
+ description=assumption.description,
205
+ value=value_text,
206
+ default_value=format_value(assumption.default_value) if overridden else "—",
207
+ default_edit_text=default_edit_text,
208
+ status=_STATUS_LABELS[assumption.provenance],
209
+ usage=usage,
210
+ source=assumption.source,
211
+ recorded=format_recorded(assumption.recorded_on),
212
+ structured=structured,
213
+ edit_text=table_edit_text(assumption.value) if structured else value_text,
214
+ )
215
+
216
+
217
+ def _glide_path_text(config: GlidePathConfig | None) -> str:
218
+ """A person's glide-path source as display text, knots included."""
219
+ if config is None:
220
+ return "Default glide path shape assumption"
221
+ knots = "; ".join(
222
+ f"{point.years_to_retirement}y out: {_allocation_text(point.allocation)}"
223
+ for point in config.points
224
+ )
225
+ return f"Custom — {knots}"
226
+
227
+
228
+ def _allocation_text(allocation: AssetAllocation | None) -> str:
229
+ """A wrapper's asset-allocation source as display text."""
230
+ if allocation is None:
231
+ return "From the glide path"
232
+ return (
233
+ f"Equity {allocation.equity} / bonds {allocation.bonds}"
234
+ f" / cash {allocation.cash}"
235
+ )
236
+
237
+
238
+ def _escalation_text(escalation: AssumptionKey | None) -> str:
239
+ """A contribution schedule's escalation as display text."""
240
+ if escalation is None:
241
+ return "None (fixed amounts)"
242
+ if escalation is AssumptionKey.EARNINGS_GROWTH_REAL:
243
+ return "Grows with earnings"
244
+ return f"Grows with the {format_assumption_key(escalation.value)} assumption"
245
+
246
+
247
+ def _fees_text(fees: FeeSchedule | None) -> str:
248
+ """A wrapper's fee source as display text."""
249
+ if fees is None:
250
+ return "Shipped platform and fund fee assumptions"
251
+ return f"Platform {fees.platform.value} / fund {fees.fund.value}"
252
+
253
+
254
+ def _revaluation_text(basis: RevaluationBasis) -> str:
255
+ """A DB scheme's revaluation basis as display text."""
256
+ if basis.fixed_rate is not None: # FIXED, by the RevaluationBasis invariant
257
+ return f"Fixed {basis.fixed_rate.value} per year"
258
+ if basis.reference is RevaluationReference.CPI:
259
+ if basis.cap is None:
260
+ return "CPI"
261
+ return f"CPI, capped at {basis.cap.value}"
262
+ return "None (frozen in nominal terms)"
263
+
264
+
265
+ def _factors_text(table: FactorTable) -> str:
266
+ """A DB scheme's early/late factor table as display text."""
267
+ if not table.factors:
268
+ return "None (taken at the normal pension age)"
269
+ return "; ".join(
270
+ f"{age}: {factor}" for age, factor in sorted(table.factors.items())
271
+ )
272
+
273
+
274
+ def _stage_multipliers_text(multipliers: Mapping[LifeStage, Decimal] | None) -> str:
275
+ """A spending plan's life-stage multipliers as display text."""
276
+ if not multipliers:
277
+ return "None (flat spending)"
278
+ return "; ".join(
279
+ f"{format_value(stage)}: {multiplier}"
280
+ for stage, multiplier in sorted(
281
+ multipliers.items(), key=lambda item: item[0].value
282
+ )
283
+ )
284
+
285
+
286
+ def _person_structure(person: Person, name: str) -> list[StructureRow]:
287
+ """One person's structural inputs as display rows (issue #70)."""
288
+ residency = str(person.tax_residency)
289
+ return [
290
+ StructureRow(name, "Tax residency", _RESIDENCY_NAMES.get(residency, residency)),
291
+ StructureRow(name, "Glide path", _glide_path_text(person.glide_path)),
292
+ ]
293
+
294
+
295
+ def _wrapper_structure(wrapper: Wrapper, name: str) -> list[StructureRow]:
296
+ """One wrapper's structural inputs as display rows (issue #70)."""
297
+ rows = [StructureRow(name, "Wrapper kind", format_wrapper_kind(wrapper.kind))]
298
+ schedule = wrapper.contributions
299
+ if schedule is not None:
300
+ relief = (
301
+ format_value(schedule.relief_mechanic)
302
+ if schedule.relief_mechanic is not None
303
+ else "No tax relief"
304
+ )
305
+ rows.append(StructureRow(name, "Contribution relief", relief))
306
+ rows.append(
307
+ StructureRow(
308
+ name, "Contribution escalation", _escalation_text(schedule.escalation)
309
+ )
310
+ )
311
+ rows.append(
312
+ StructureRow(name, "Asset allocation", _allocation_text(wrapper.allocation))
313
+ )
314
+ rows.append(StructureRow(name, "Fees", _fees_text(wrapper.fees)))
315
+ return rows
316
+
317
+
318
+ def _db_structure(pension: DBPension, name: str) -> list[StructureRow]:
319
+ """One DB pension's scheme structure as display rows (issue #70).
320
+
321
+ Membership is structural — active accrual's rate and salary are
322
+ ``Fact``-wrapped and appear as provenance rows instead (9.6).
323
+ """
324
+ membership = (
325
+ "Deferred" if pension.active_membership is None else "Active (accruing)"
326
+ )
327
+ return [
328
+ StructureRow(name, "Membership", membership),
329
+ StructureRow(name, "Statement date", format_date(pension.statement_date)),
330
+ StructureRow(name, "Revaluation", _revaluation_text(pension.revaluation_basis)),
331
+ StructureRow(
332
+ name, "Early/late factors", _factors_text(pension.early_late_factors)
333
+ ),
334
+ ]
335
+
336
+
337
+ def _annuity_structure(purchase: AnnuityPurchase, name: str) -> list[StructureRow]:
338
+ """One annuity purchase's structural choices as display rows (issue #70)."""
339
+ return [
340
+ StructureRow(name, "Annuity type", format_value(purchase.annuity_type)),
341
+ StructureRow(name, "Annuity basis", format_value(purchase.basis)),
342
+ ]
343
+
344
+
345
+ def _structure_rows(household: Household | None) -> tuple[StructureRow, ...]:
346
+ """Structural plan inputs, entity-level (planning §5.1, issue #70).
347
+
348
+ These fields drive results but are neither ``Fact`` nor
349
+ ``Decision`` wrapped, so they never appear as provenance rows —
350
+ the persisted plan itself is their record, and this section is
351
+ where the UI shows it.
352
+ """
353
+ if household is None:
354
+ return ()
355
+ names = entity_names(household)
356
+ rows: list[StructureRow] = []
357
+ for person in household.persons:
358
+ rows.extend(_person_structure(person, names[str(person.id)]))
359
+ for wrapper in person.wrappers:
360
+ rows.extend(_wrapper_structure(wrapper, names[str(wrapper.id)]))
361
+ for pension in person.db_pensions:
362
+ rows.extend(_db_structure(pension, names[str(pension.id)]))
363
+ for purchase in person.annuity_purchases:
364
+ rows.extend(_annuity_structure(purchase, names[str(purchase.id)]))
365
+ if household.spending is not None:
366
+ rows.append(
367
+ StructureRow(
368
+ "Household",
369
+ "Spending stages",
370
+ _stage_multipliers_text(household.spending.stage_multipliers),
371
+ )
372
+ )
373
+ for outflow in household.planned_outflows:
374
+ person_id, age = outflow.at_age_of
375
+ rows.append(
376
+ StructureRow(outflow.label, "Due", f"{names[str(person_id)]} at age {age}")
377
+ )
378
+ return tuple(rows)
379
+
380
+
381
+ def _assumption_rows(state: PlanState) -> tuple[AssumptionRow, ...]:
382
+ """Read assumptions in first-read order, then the rest of the catalogue."""
383
+ read = state.result.provenance.assumptions if state.result is not None else ()
384
+ read_keys = {assumption.key for assumption in read}
385
+ has_result = state.result is not None
386
+
387
+ def unread_usage(key: AssumptionKey) -> str:
388
+ if not has_result:
389
+ return _NO_RUN_LABEL
390
+ if key in _REGION_BUILD_KEYS:
391
+ return _REGION_BUILD_LABEL
392
+ return _UNUSED_LABEL
393
+
394
+ rows = [_assumption_row(assumption, _USED_LABEL) for assumption in read]
395
+ rows.extend(
396
+ _assumption_row(state.assumptions.get(key), unread_usage(key))
397
+ for key in sorted(state.assumptions.keys - read_keys, key=str)
398
+ )
399
+ return tuple(rows)
400
+
401
+
402
+ def _summary_lines(state: PlanState) -> tuple[str, str]:
403
+ """The human summary line plus the full run-manifest detail.
404
+
405
+ The manifest string (data-file digests, policy parameters, seed) is
406
+ load-bearing §4.6 provenance but not reading matter, so the summary
407
+ stays human and the exact manifest rides along as ``summary_detail``
408
+ for the shell to reveal on demand (a tooltip in the Qt shell).
409
+ """
410
+ if state.run_error is not None:
411
+ return f"The projection failed: {state.run_error}", ""
412
+ if state.result is None:
413
+ return (
414
+ "No projection yet: enter your facts and save them. The shipped "
415
+ "default assumptions below apply until you override them."
416
+ ), ""
417
+ seed = state.result.provenance.seed
418
+ seed_text = "none (deterministic)" if seed is None else str(seed)
419
+ run_kind = "deterministic run" if seed is None else f"Monte Carlo run, seed {seed}"
420
+ years = len(state.result.snapshots)
421
+ start = format_date(state.result.config.today)
422
+ summary = (
423
+ f"Projected across {years} tax years from {start} ({run_kind}) — the "
424
+ "first and last may be partial. Hover for the run manifest: the "
425
+ "exact rule data and policies behind these numbers."
426
+ )
427
+ version = state.result.provenance.region_data_version
428
+ return summary, f"Run manifest — {version}; seed: {seed_text}"
429
+
430
+
431
+ def build_inspector_view_model(state: PlanState) -> InspectorViewModel:
432
+ """Assemble the stated-vs-assumed screen from the session state.
433
+
434
+ Facts and decisions come straight from
435
+ ``ProjectionResult.provenance`` (roadmap 8.3 acceptance); the
436
+ assumptions column carries value, source, date recorded, and
437
+ default-vs-overridden status for every row (planning §1).
438
+ """
439
+ names = entity_names(state.household)
440
+ provenance = state.result.provenance if state.result is not None else None
441
+ facts = tuple(
442
+ FactRow(
443
+ label=display_label(labelled.label, names),
444
+ value=format_value(labelled.fact.value),
445
+ as_of=format_date(labelled.fact.as_of),
446
+ recorded=format_recorded(labelled.fact.recorded_on),
447
+ note=labelled.fact.note or "",
448
+ )
449
+ for labelled in (provenance.facts if provenance is not None else ())
450
+ )
451
+ decisions = tuple(
452
+ DecisionRow(
453
+ label=display_label(labelled.label, names),
454
+ value=format_value(labelled.decision.value),
455
+ recorded=format_recorded(labelled.decision.recorded_on),
456
+ note=labelled.decision.note or "",
457
+ )
458
+ for labelled in (provenance.decisions if provenance is not None else ())
459
+ )
460
+ roll_forwards = tuple(
461
+ RollForwardRow(
462
+ label=display_label(entry.label, names),
463
+ stated=format_money(entry.stated),
464
+ as_of=format_date(entry.as_of),
465
+ months=str(entry.months),
466
+ opening=format_money(entry.opening),
467
+ )
468
+ for entry in (
469
+ provenance.balance_roll_forwards if provenance is not None else ()
470
+ )
471
+ )
472
+ summary, summary_detail = _summary_lines(state)
473
+ return InspectorViewModel(
474
+ title="Stated vs assumed",
475
+ facts_heading="Facts you stated",
476
+ facts_columns=("Fact", "Value", "As of", "Recorded"),
477
+ facts=facts,
478
+ roll_forwards_heading="Stated amounts rolled forward to today",
479
+ roll_forwards_columns=("Fact", "Stated", "As of", "Months", "Value today"),
480
+ roll_forwards=roll_forwards,
481
+ assumptions_heading="Assumptions used",
482
+ assumptions_columns=(
483
+ "Assumption",
484
+ "Value",
485
+ "Default",
486
+ "Status",
487
+ "Used",
488
+ "Source",
489
+ "Recorded",
490
+ ),
491
+ assumptions=_assumption_rows(state),
492
+ decisions_heading="Your choices in effect",
493
+ decisions_columns=("Choice", "Value", "Recorded"),
494
+ decisions=decisions,
495
+ structure_heading="Plan structure",
496
+ structure_columns=("Entity", "Setting", "Value"),
497
+ structure=_structure_rows(state.household),
498
+ summary=summary,
499
+ summary_detail=summary_detail,
500
+ override_title="Override assumption",
501
+ override_prompt="New value (blank restores the shipped default):",
502
+ table_override_prompt=(
503
+ "New value — one 'key = value' line per figure, dotted keys for"
504
+ " nested tables (blank restores the shipped default):"
505
+ ),
506
+ )
@@ -0,0 +1,66 @@
1
+ """Shared display text for provenance labels and entity ids (§4.7).
2
+
3
+ The provenance grammar (``person[<id>].field.path``, see
4
+ :func:`~glidepath.core.collect_plan_decisions`) addresses entities by
5
+ stable id. These helpers turn those labels into copy — ids named for
6
+ humans — for every screen that shows labelled facts, decisions, or
7
+ scenario overrides (the inspector and the scenario manager).
8
+ """
9
+
10
+ import re
11
+ from typing import TYPE_CHECKING
12
+
13
+ from glidepath.app.display import format_wrapper_kind
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ from glidepath.core import Household
19
+
20
+ _LABEL_PATTERN = re.compile(r"^(?P<kind>[a-z_]+)\[(?P<id>[^\]]+)\]\.(?P<path>.+)$")
21
+
22
+
23
+ def capitalised(text: str) -> str:
24
+ """The text with just its first letter upper-cased."""
25
+ return text[:1].upper() + text[1:]
26
+
27
+
28
+ def pretty_path(path: str) -> str:
29
+ """A dotted field path as display text."""
30
+ return " / ".join(segment.replace("_", " ") for segment in path.split("."))
31
+
32
+
33
+ def entity_names(household: Household | None) -> dict[str, str]:
34
+ """Friendly names for the entity ids provenance labels address."""
35
+ if household is None:
36
+ return {}
37
+ names: dict[str, str] = {}
38
+ for person in household.persons:
39
+ names[str(person.id)] = "You"
40
+ for number, wrapper in enumerate(person.wrappers, start=1):
41
+ kind = format_wrapper_kind(wrapper.kind)
42
+ names[str(wrapper.id)] = f"Wrapper {number} ({kind})"
43
+ for number, pension in enumerate(person.db_pensions, start=1):
44
+ names[str(pension.id)] = f"DB pension {number}"
45
+ for number, purchase in enumerate(person.annuity_purchases, start=1):
46
+ names[str(purchase.id)] = f"Annuity purchase {number}"
47
+ for outflow in household.planned_outflows:
48
+ names[str(outflow.id)] = outflow.label
49
+ return names
50
+
51
+
52
+ def display_label(label: str, names: Mapping[str, str]) -> str:
53
+ """A provenance label as display text, entity ids named for humans."""
54
+ match = _LABEL_PATTERN.match(label)
55
+ if match is None:
56
+ return capitalised(pretty_path(label))
57
+ name = names.get(match["id"], capitalised(match["kind"].replace("_", " ")))
58
+ return f"{name} — {pretty_path(match['path'])}"
59
+
60
+
61
+ __all__ = [
62
+ "capitalised",
63
+ "display_label",
64
+ "entity_names",
65
+ "pretty_path",
66
+ ]