fix(fire-target): Family/home targets monotonic (kills Family==Household)
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 <noreply@anthropic.com>
This commit is contained in:
parent
6efe3b0c31
commit
1b8809a01b
2 changed files with 33 additions and 3 deletions
|
|
@ -494,6 +494,16 @@ async def _recompute_fire_targets(
|
||||||
)
|
)
|
||||||
jur = jurisdiction_for_city(slug)
|
jur = jurisdiction_for_city(slug)
|
||||||
kids_cf = kids_annual_spend(ratios, kids_base=kids_base)
|
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):
|
for case in (Case.SOLO, Case.HOUSEHOLD, Case.FAMILY):
|
||||||
spend = case_base_spend(case, ratios)
|
spend = case_base_spend(case, ratios)
|
||||||
home_variants = (False, True) if case is Case.FAMILY else (False,)
|
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,
|
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,
|
with_home=with_home, home_amount_gbp=home_amount, home_year=home_year,
|
||||||
)
|
)
|
||||||
# Bound the search to a sane SWR band (spend × 60 ≈
|
# hi = sane SWR band (spend × 60 ≈ 1.67% floor); lo =
|
||||||
# 1.67% floor) so the binary search converges fast.
|
# 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(
|
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)
|
await upsert_fire_target(sess, inp, res, n_paths)
|
||||||
written += 1
|
written += 1
|
||||||
tag = "+home" if with_home else ""
|
tag = "+home" if with_home else ""
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,20 @@ def test_unreachable_bar_returns_not_reached() -> None:
|
||||||
# Spend far above what any NW in range can sustain.
|
# 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)
|
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
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue