retiretui_engine 0.1.0

Plan model, tax engine, and projection for RetireTui.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! Tax parameters: per-year TOML tables, embedded for known years, extended
//! past the last known year by inflating indexed values. Values are data;
//! rule shapes live in [`crate::tax`].

use std::collections::BTreeMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::plan::{Dollars, FilingStatus};

mod index;

pub use index::Inflation;
use index::inflate;
pub(crate) use index::scale;

/// The tax parameter file schema version this build reads.
const PARAMS_SCHEMA_VERSION: u32 = 1;

const EMBEDDED: &[&str] = &[include_str!("../../tax/2026.toml")];

/// Errors from loading tax parameter files.
#[derive(Debug, thiserror::Error)]
pub enum ParamsError {
    /// A parameter file or directory could not be read.
    #[error("failed to read {path}: {source}")]
    Io {
        /// The offending path.
        path: String,
        /// The underlying error.
        source: std::io::Error,
    },
    /// A parameter file failed to parse.
    #[error("failed to parse {path}: {source}")]
    Parse {
        /// The offending path.
        path: String,
        /// The underlying error.
        source: Box<toml::de::Error>,
    },
    /// A parameter file declares an unsupported schema version.
    #[error(
        "{path}: unsupported schema version {found} (this build reads {PARAMS_SCHEMA_VERSION})"
    )]
    Schema {
        /// The offending path.
        path: String,
        /// The version the file declares.
        found: u32,
    },
}

/// A value per filing status.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PerStatus<T> {
    /// Single filer value.
    pub single: T,
    /// Married-filing-jointly value.
    pub married_joint: T,
}

impl<T> PerStatus<T> {
    /// The value for a filing status, by reference.
    #[must_use]
    pub fn for_status(&self, status: FilingStatus) -> &T {
        match status {
            FilingStatus::Single => &self.single,
            FilingStatus::MarriedJoint => &self.married_joint,
        }
    }
}

impl<T: Copy> PerStatus<T> {
    /// The value for a filing status.
    #[must_use]
    pub fn get(&self, status: FilingStatus) -> T {
        *self.for_status(status)
    }
}

/// One ordinary-income bracket: `rate` applies above `over`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Bracket {
    /// Taxable income where this rate starts.
    pub over: Dollars,
    /// Marginal rate.
    pub rate: f64,
    /// Set where the law fixes `over` in nominal dollars, so that it is not
    /// inflated past the last known year.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub unindexed: bool,
}

/// Standard deductions.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Deductions {
    /// Standard deduction per filing status.
    pub standard: PerStatus<Dollars>,
}

/// Long-term capital gains thresholds and rates.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Ltcg {
    /// Rate for gains stacked above the zero band.
    pub middle_rate: f64,
    /// Rate for gains above `fifteen_until`.
    pub top_rate: f64,
    /// Taxable income up to which gains are untaxed.
    pub zero_until: PerStatus<Dollars>,
    /// Taxable income up to which gains take `middle_rate`.
    pub fifteen_until: PerStatus<Dollars>,
}

/// Social Security parameters: the statutory, unindexed provisional-income
/// thresholds, and what the benefit formula reads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct SocialSecurityThresholds {
    /// Below this, no benefit is taxable.
    pub provisional_base: PerStatus<Dollars>,
    /// Above this, up to 85% is taxable.
    pub provisional_upper: PerStatus<Dollars>,
    /// What computing a benefit from an earnings record needs; absent in
    /// an override file that predates it, and a derived benefit is then
    /// refused by validation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub benefit: Option<BenefitParams>,
}

/// What a benefit is computed from: the published average wage index and
/// the growth assumed past it. The wage base and the bend points of any
/// year derive from the index by statute.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct BenefitParams {
    /// Annual growth of the average wage past the last year the index
    /// carries.
    pub wage_growth: f64,
    /// The national average wage index by year, as published.
    pub wage_index: BTreeMap<i16, f64>,
}

/// The 1994 contribution and benefit base; later bases scale it by the
/// average wage two years earlier over 1992's, to the nearest $300.
const WAGE_BASE_1994: Dollars = 60_600;
const WAGE_BASE_YEAR: i16 = 1994;
const WAGE_BASE_STEP: Dollars = 300;
/// The 1979 bend points; a later eligibility year scales them by the
/// average wage two years earlier over 1977's, to the dollar.
const BEND_POINTS_1979: [Dollars; 2] = [180, 1_085];
const BEND_POINTS_YEAR: i16 = 1979;
/// Years between an amount's year and the average wage it is set from.
const INDEX_LAG_YEARS: i16 = 2;

impl BenefitParams {
    /// The average wage of `year`: as published, or the last published
    /// grown at `wage_growth` a year; none before the index begins.
    #[must_use]
    pub fn average_wage(&self, year: i16) -> Option<f64> {
        if let Some(&wage) = self.wage_index.get(&year) {
            return Some(wage);
        }
        let (&last_year, &last) = self.wage_index.last_key_value()?;
        (year > last_year)
            .then(|| last * (1.0 + self.wage_growth).powi(i32::from(year - last_year)))
    }

    /// The contribution and benefit base of `year`, above which covered
    /// earnings count for nothing.
    #[must_use]
    pub fn wage_base(&self, year: i16) -> Dollars {
        let scaled = WAGE_BASE_1994 as f64 * self.index_ratio(year, WAGE_BASE_YEAR);
        (scaled / WAGE_BASE_STEP as f64).round() as Dollars * WAGE_BASE_STEP
    }

    /// The two monthly amounts the PIA formula bends at for a benefit first
    /// payable in `eligibility_year`.
    #[must_use]
    pub fn bend_points(&self, eligibility_year: i16) -> [Dollars; 2] {
        let ratio = self.index_ratio(eligibility_year, BEND_POINTS_YEAR);
        BEND_POINTS_1979.map(|point| (point as f64 * ratio).round() as Dollars)
    }

    /// The average wage of `year` over that of `over`; none where the index
    /// carries neither.
    #[must_use]
    pub fn wage_ratio(&self, year: i16, over: i16) -> Option<f64> {
        Some(self.average_wage(year)? / self.average_wage(over)?)
    }

    /// The average wage set for `year` over the one set for `base_year`;
    /// one where the index carries neither.
    fn index_ratio(&self, year: i16, base_year: i16) -> f64 {
        self.wage_ratio(year - INDEX_LAG_YEARS, base_year - INDEX_LAG_YEARS)
            .unwrap_or(1.0)
    }
}

/// Early-withdrawal parameters.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EarlyWithdrawal {
    /// Penalty rate on early distributions.
    pub penalty: f64,
}

/// One row of the RMD Uniform Lifetime Table.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RmdDivisor {
    /// Age reached during the distribution year.
    pub age: u8,
    /// The divisor applied to the prior year-end balance.
    pub divisor: f64,
}

/// The RMD table.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RmdTable {
    /// Rows in ascending age order.
    pub divisors: Vec<RmdDivisor>,
}

/// A MAGI band over which something phases out: whole below `from`, gone
/// at `to`, linear between.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct PhaseOut {
    /// MAGI at or below which nothing is lost.
    pub from: Dollars,
    /// MAGI at or above which all is lost.
    pub to: Dollars,
}

impl PhaseOut {
    /// How far into the band `magi` is, from 0 at its foot to 1 at its top.
    #[must_use]
    pub fn position(self, magi: Dollars) -> f64 {
        if magi <= self.from {
            return 0.0;
        }
        if magi >= self.to || self.to <= self.from {
            return 1.0;
        }
        (magi - self.from) as f64 / (self.to - self.from) as f64
    }
}

/// Annual contribution limits.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct ContributionLimits {
    /// Elective deferral for 401(k)/403(b)/457(b).
    pub employer_plan: Dollars,
    /// Additional deferral from age 50.
    pub employer_plan_catch_up_50: Dollars,
    /// Replacement catch-up at ages 60-63.
    pub employer_plan_catch_up_60: Dollars,
    /// Everything paid into one defined-contribution plan in a year, by
    /// employee and employer together (415(c)).
    pub overall_plan: Dollars,
    /// SIMPLE IRA deferral.
    pub simple: Dollars,
    /// SIMPLE additional deferral from age 50.
    pub simple_catch_up_50: Dollars,
    /// SIMPLE replacement catch-up at ages 60-63.
    pub simple_catch_up_60: Dollars,
    /// IRA contribution (traditional plus Roth combined).
    pub ira: Dollars,
    /// IRA additional contribution from age 50.
    pub ira_catch_up_50: Dollars,
    /// HSA limit with self-only coverage.
    pub hsa_self: Dollars,
    /// HSA limit with family coverage.
    pub hsa_family: Dollars,
    /// HSA additional contribution from age 55.
    pub hsa_catch_up_55: Dollars,
    /// The MAGI band over which a Roth IRA contribution is no longer
    /// allowed.
    pub roth_ira_phase_out: PerStatus<PhaseOut>,
    /// The MAGI band over which a traditional IRA contribution stops being
    /// deductible for a person a workplace plan covers.
    pub ira_deduction_phase_out: PerStatus<PhaseOut>,
}

/// One IRMAA tier: the MAGI threshold it starts above and the annual
/// surcharges per covered person.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct IrmaaTier {
    /// The tier applies when the lookback MAGI exceeds this.
    pub magi_over: PerStatus<Dollars>,
    /// Annual Part B surcharge per covered person.
    pub part_b: Dollars,
    /// Annual Part D surcharge per covered person.
    pub part_d: Dollars,
}

/// Every tax parameter for one year.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct TaxParams {
    /// Parameter file schema version.
    pub schema: u32,
    /// The tax year these values apply to.
    pub year: i16,
    /// Deductions.
    pub deductions: Deductions,
    /// Ordinary-income brackets per filing status.
    pub brackets: PerStatus<Vec<Bracket>>,
    /// Long-term capital gains parameters.
    pub ltcg: Ltcg,
    /// Social Security taxation thresholds.
    pub social_security: SocialSecurityThresholds,
    /// Early-withdrawal parameters.
    pub early_withdrawal: EarlyWithdrawal,
    /// Contribution limits.
    pub limits: ContributionLimits,
    /// The RMD table.
    pub rmd: RmdTable,
    /// IRMAA surcharge tiers, ascending by threshold; empty disables
    /// surcharges (older override files carry none).
    #[serde(default)]
    pub irmaa: Vec<IrmaaTier>,
    /// State income tax tables by lowercase state code; a state absent here
    /// is not modeled.
    #[serde(default)]
    pub states: BTreeMap<String, StateParams>,
}

/// One state's income tax. A state with no income tax is an empty table,
/// which is not the same as a state with none: that one is not modeled.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct StateParams {
    /// Standard deduction by filing status.
    pub deduction: PerStatus<Dollars>,
    /// Brackets by filing status, walked as the federal ones are.
    pub brackets: PerStatus<Vec<Bracket>>,
    /// Whether the federally taxable share of Social Security is taxed.
    pub taxes_social_security: bool,
}

/// All known tax years, ready to answer any projection year.
#[derive(Debug, Clone, PartialEq)]
pub struct TaxTables {
    years: BTreeMap<i16, TaxParams>,
}

impl TaxTables {
    /// The tables compiled into the binary.
    ///
    /// # Panics
    ///
    /// Panics if an embedded parameter file does not parse, which a unit test
    /// prevents from shipping.
    #[must_use]
    pub fn embedded() -> Self {
        let mut tables = Self {
            years: BTreeMap::new(),
        };
        for text in EMBEDDED {
            tables
                .add_source(text, "embedded")
                .expect("embedded tax parameter files are valid");
        }
        tables
    }

    /// Parses one parameter file's text and stores it, replacing any table
    /// already held for that year.
    ///
    /// # Errors
    ///
    /// Returns [`ParamsError::Parse`] or [`ParamsError::Schema`]; `label`
    /// names the source in the error.
    pub fn add_source(&mut self, text: &str, label: &str) -> Result<(), ParamsError> {
        let params: TaxParams = toml::from_str(text).map_err(|source| ParamsError::Parse {
            path: label.to_owned(),
            source: Box::new(source),
        })?;
        if params.schema != PARAMS_SCHEMA_VERSION {
            return Err(ParamsError::Schema {
                path: label.to_owned(),
                found: params.schema,
            });
        }
        self.years.insert(params.year, params);
        Ok(())
    }

    /// Loads every `*.toml` file in a directory, overriding embedded years.
    /// A missing directory is fine; it simply adds nothing.
    ///
    /// # Errors
    ///
    /// Returns [`ParamsError::Io`] when a present directory or file cannot be
    /// read, and parse/schema errors from [`TaxTables::add_source`].
    pub fn add_dir(&mut self, dir: &Path) -> Result<(), ParamsError> {
        if !dir.is_dir() {
            return Ok(());
        }
        let entries = std::fs::read_dir(dir).map_err(|source| ParamsError::Io {
            path: dir.display().to_string(),
            source,
        })?;
        for entry in entries {
            let path = entry
                .map_err(|source| ParamsError::Io {
                    path: dir.display().to_string(),
                    source,
                })?
                .path();
            if path.extension().is_some_and(|ext| ext == "toml") {
                let text = std::fs::read_to_string(&path).map_err(|source| ParamsError::Io {
                    path: path.display().to_string(),
                    source,
                })?;
                self.add_source(&text, &path.display().to_string())?;
            }
        }
        Ok(())
    }

    /// The latest year with a real (non-extended) table.
    #[must_use]
    pub fn latest_known_year(&self) -> Option<i16> {
        self.years.keys().next_back().copied()
    }

    /// Parameters for a year. A year between known tables uses the nearest
    /// table at or below it; a year past the last known table is that table
    /// with its indexed dollar values carried to `year` by `inflation`.
    /// Statutory unindexed values (Social Security thresholds, RMD divisors,
    /// rates) are never scaled.
    ///
    /// # Panics
    ///
    /// Panics if no tables are loaded; [`TaxTables::embedded`] always holds
    /// at least one.
    #[must_use]
    pub fn params_for(&self, year: i16, inflation: &Inflation) -> TaxParams {
        let (&base_year, base) = self
            .years
            .range(..=year)
            .next_back()
            .or_else(|| self.years.iter().next())
            .expect("at least one tax table is loaded");
        if year <= base_year {
            let mut params = base.clone();
            params.year = year;
            return params;
        }
        inflate(base, year, inflation.factor(base_year, year))
    }
}