From 1b8809a01b76f63ff5183360f147e6688ae0f470 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 1 Jul 2026 22:42:34 +0000 Subject: [PATCH] fix(fire-target): Family/home targets monotonic (kills Family==Household) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recompute solved each Case's FIRE number with an independent binary search, so Monte-Carlo path noise + the coarse £15k tolerance made Family (Household + 2 kids) tie or even UNDERCUT Household (6 hard inversions + 5 exact ties across the 22 countries) — the ~£20k kids cost quantised to ~£0. Now solve the Cases in increasing-cost order and lower-bound each by the previous Case's target on the SAME return paths: a heavier Case (more spend / +kids / +home) can never need less net worth than a lighter one, so Solo <= Household <= Family <= Family+home holds by construction. tol tightened 15k -> 1k so the genuine but small kids/home increment resolves instead of snapping to the previous grid step. Kids/home were already modelled correctly (verified) — this is purely a solver-resolution + monotonicity fix. Found + verified via the fire-countdown flaw-hunt workflow. 346 tests pass. Co-Authored-By: Claude Opus 4.8 --- fire_planner/__main__.py | 19 ++++++++++++++++--- tests/test_fire_target.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/fire_planner/__main__.py b/fire_planner/__main__.py index 756911d..339e445 100644 --- a/fire_planner/__main__.py +++ b/fire_planner/__main__.py @@ -494,6 +494,16 @@ async def _recompute_fire_targets( ) jur = jurisdiction_for_city(slug) kids_cf = kids_annual_spend(ratios, kids_base=kids_base) + # Solve the Cases in increasing-cost order and lower-bound each + # by the previous Case's target on the SAME return paths. A + # heavier Case (more spend / +kids / +home) can never need LESS + # net worth than a lighter one, so this guarantees + # Solo <= Household <= Family <= Family+home by construction — + # killing the Monte-Carlo-noise inversions where Family looked + # cheaper than Household. tol is tight (£1k) so the genuine but + # small kids/home increment (~£20k at the 99% bar) resolves + # instead of being quantised to the previous grid step. + prev_target = 0.0 for case in (Case.SOLO, Case.HOUSEHOLD, Case.FAMILY): spend = case_base_spend(case, ratios) home_variants = (False, True) if case is Case.FAMILY else (False,) @@ -509,10 +519,13 @@ async def _recompute_fire_targets( kids_start_year=KIDS_START_YEAR, kids_end_year=KIDS_END_YEAR, with_home=with_home, home_amount_gbp=home_amount, home_year=home_year, ) - # Bound the search to a sane SWR band (spend × 60 ≈ - # 1.67% floor) so the binary search converges fast. + # hi = sane SWR band (spend × 60 ≈ 1.67% floor); lo = + # previous (lighter) Case's target -> monotone chain + + # a small band for the nested solves (fast). + hi = min(5_000_000.0, spend * 60.0) res = solve_target_nw( - paths, inp, hi=min(5_000_000.0, spend * 60.0), tol=15_000.0) + paths, inp, lo=min(prev_target, hi), hi=hi, tol=1_000.0) + prev_target = float(res.target_nw_gbp) await upsert_fire_target(sess, inp, res, n_paths) written += 1 tag = "+home" if with_home else "" diff --git a/tests/test_fire_target.py b/tests/test_fire_target.py index 2da811f..bb4e852 100644 --- a/tests/test_fire_target.py +++ b/tests/test_fire_target.py @@ -112,3 +112,20 @@ def test_unreachable_bar_returns_not_reached() -> None: # Spend far above what any NW in range can sustain. res = solve_target_nw(_paths(), _inp(annual_spend_gbp=2_000_000.0), hi=1_000_000.0, tol=2_000.0) assert not res.reached_bar + + +def test_nested_lo_bound_guarantees_case_monotonicity() -> None: + """The recompute solves Cases in increasing-cost order, lower-bounding each + by the previous target on the same paths. That must yield + Solo <= Household <= Family <= Family+home with NO inversions — the fix for + the 'Family == / < Household' Monte-Carlo-noise flaw.""" + paths = _paths() + solo = solve_target_nw(paths, _inp(annual_spend_gbp=30_000.0), tol=1_000.0) + hh = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0), + lo=solo.target_nw_gbp, tol=1_000.0) + fam = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0, kids_annual_gbp=12_000.0), + lo=hh.target_nw_gbp, tol=1_000.0) + famh = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0, kids_annual_gbp=12_000.0, + with_home=True, home_amount_gbp=100_000.0), + lo=fam.target_nw_gbp, tol=1_000.0) + assert solo.target_nw_gbp <= hh.target_nw_gbp <= fam.target_nw_gbp <= famh.target_nw_gbp