1- """Budget-window result validation and aggregation helpers."""
1+ """Budget-window annual result extraction and aggregation helpers."""
22
33from __future__ import annotations
44
55from decimal import Decimal
66from typing import Any
77
88from src .modal .gateway .models import (
9+ BudgetWindowAnnualImpact ,
910 BudgetWindowResult ,
1011 BudgetWindowTotals ,
11- SingleYearMacroOutput ,
1212)
1313
1414# The UK microsimulation has no state/province fiscal layer, so worker child
2020 "benefit_spending_impact" ,
2121 "budgetary_impact" ,
2222)
23-
24-
25- def _is_number (value : Any ) -> bool :
26- return isinstance (value , int | float ) and not isinstance (value , bool )
23+ OPTIONAL_BUDGET_KEYS = ("state_tax_revenue_impact" ,)
2724
2825
2926def _as_decimal (value : float | int ) -> Decimal :
@@ -35,60 +32,46 @@ def _as_decimal(value: float | int) -> Decimal:
3532 return Decimal (str (value ))
3633
3734
38- def validate_single_year_output (
35+ def extract_annual_impact (
3936 * ,
4037 simulation_year : str ,
4138 child_result : dict [str , Any ],
42- ) -> SingleYearMacroOutput :
43- """Validate and normalize a child macro result.
44-
45- UK worker results can omit ``state_tax_revenue_impact`` because there is
46- no state/province fiscal layer. The canonical output still includes that
47- field, defaulted to zero, so downstream clients receive one stable shape.
48- """
49-
50- if not isinstance (child_result , dict ):
51- raise ValueError (
52- "Malformed budget-window child result: expected object for "
53- f"{ simulation_year } "
54- )
55-
39+ ) -> BudgetWindowAnnualImpact :
5640 budget = child_result .get ("budget" , {})
5741 if not isinstance (budget , dict ):
5842 raise ValueError ("Malformed budget-window child result: missing budget object" )
5943
6044 missing_keys = [
61- key for key in REQUIRED_BUDGET_KEYS if not _is_number (budget .get (key ))
45+ key
46+ for key in REQUIRED_BUDGET_KEYS
47+ if not isinstance (budget .get (key ), int | float )
6248 ]
6349 if missing_keys :
6450 missing = ", " .join (f"budget.{ key } " for key in missing_keys )
6551 raise ValueError (
6652 f"Malformed budget-window child result: missing numeric { missing } "
6753 )
6854
69- normalized = dict (child_result )
70- normalized_budget = dict (budget )
71- if "state_tax_revenue_impact" not in normalized_budget :
72- normalized_budget ["state_tax_revenue_impact" ] = 0.0
73- elif not _is_number (normalized_budget ["state_tax_revenue_impact" ]):
74- raise ValueError (
75- "Malformed budget-window child result: missing numeric "
76- "budget.state_tax_revenue_impact"
77- )
78- normalized ["budget" ] = normalized_budget
79-
80- try :
81- return SingleYearMacroOutput .model_validate (normalized )
82- except Exception as exc :
83- raise ValueError (
84- f"Malformed budget-window child result for { simulation_year } : { exc } "
85- ) from exc
55+ tax_revenue_impact = budget ["tax_revenue_impact" ]
56+ # UK worker results omit the state fiscal layer entirely; coerce to 0.0
57+ # so the parent aggregator can still report federal/state splits with a
58+ # uniform shape across countries.
59+ state_tax_revenue_impact = budget .get ("state_tax_revenue_impact" )
60+ if not isinstance (state_tax_revenue_impact , int | float ):
61+ state_tax_revenue_impact = 0.0
62+
63+ return BudgetWindowAnnualImpact (
64+ year = simulation_year ,
65+ taxRevenueImpact = tax_revenue_impact ,
66+ federalTaxRevenueImpact = tax_revenue_impact - state_tax_revenue_impact ,
67+ stateTaxRevenueImpact = state_tax_revenue_impact ,
68+ benefitSpendingImpact = budget ["benefit_spending_impact" ],
69+ budgetaryImpact = budget ["budgetary_impact" ],
70+ )
8671
8772
88- def sum_single_year_outputs (
89- * ,
90- outputs_by_year : dict [str , SingleYearMacroOutput ],
91- years : list [str ],
73+ def sum_annual_impacts (
74+ annual_impacts : list [BudgetWindowAnnualImpact ],
9275) -> BudgetWindowTotals :
9376 """Sum per-year impacts using Decimal accumulators.
9477
@@ -110,21 +93,18 @@ def sum_single_year_outputs(
11093 "budgetaryImpact" : Decimal (0 ),
11194 }
11295
113- for year in years :
114- output = outputs_by_year [year ]
115- budget = output .model_dump (mode = "json" )["budget" ]
116- tax_revenue_impact = budget ["tax_revenue_impact" ]
117- state_tax_revenue_impact = budget .get ("state_tax_revenue_impact" )
118-
119- totals ["taxRevenueImpact" ] += _as_decimal (tax_revenue_impact )
96+ for annual_impact in annual_impacts :
97+ totals ["taxRevenueImpact" ] += _as_decimal (annual_impact .taxRevenueImpact )
12098 totals ["federalTaxRevenueImpact" ] += _as_decimal (
121- tax_revenue_impact - state_tax_revenue_impact
99+ annual_impact .federalTaxRevenueImpact
100+ )
101+ totals ["stateTaxRevenueImpact" ] += _as_decimal (
102+ annual_impact .stateTaxRevenueImpact
122103 )
123- totals ["stateTaxRevenueImpact" ] += _as_decimal (state_tax_revenue_impact )
124104 totals ["benefitSpendingImpact" ] += _as_decimal (
125- budget [ "benefit_spending_impact" ]
105+ annual_impact . benefitSpendingImpact
126106 )
127- totals ["budgetaryImpact" ] += _as_decimal (budget [ "budgetary_impact" ] )
107+ totals ["budgetaryImpact" ] += _as_decimal (annual_impact . budgetaryImpact )
128108
129109 return BudgetWindowTotals (** {key : float (value ) for key , value in totals .items ()})
130110
@@ -133,25 +113,12 @@ def build_budget_window_result(
133113 * ,
134114 start_year : str ,
135115 window_size : int ,
136- outputs_by_year : dict [ str , SingleYearMacroOutput ],
116+ annual_impacts : list [ BudgetWindowAnnualImpact ],
137117) -> BudgetWindowResult :
138- years = [str (int (start_year ) + offset ) for offset in range (window_size )]
139- missing_years = [year for year in years if year not in outputs_by_year ]
140- if missing_years :
141- raise ValueError (
142- "Cannot build budget-window result: missing outputs for "
143- + ", " .join (missing_years )
144- )
145-
146- ordered_outputs = {year : outputs_by_year [year ] for year in years }
147118 return BudgetWindowResult (
148119 startYear = start_year ,
149120 endYear = str (int (start_year ) + window_size - 1 ),
150121 windowSize = window_size ,
151- years = years ,
152- outputsByYear = ordered_outputs ,
153- totals = sum_single_year_outputs (
154- outputs_by_year = ordered_outputs ,
155- years = years ,
156- ),
122+ annualImpacts = annual_impacts ,
123+ totals = sum_annual_impacts (annual_impacts ),
157124 )
0 commit comments