Power-law PF-decay tails for buffer-stock consumption functions: correct tail law, theory module, diagnostics, and a certified grid-extent criterion - #1782
Conversation
The 2D aggregate-shock solvers built each per-Mgrid consumption slice as a bare LinearInterp, extrapolating naive-linear (last segment's slope forever) above the top gridpoint -- unlike the 1D ConsIndShock / ConsMarkov solvers, which decay toward the perfect-foresight asymptote c(m) -> MPCmin*(m + hNrm). This adds the machinery, default-inert (byte-for-byte identical unless opted in): - pf_mpc_min / pf_human_wealth_markov: the PF asymptote slope and the per-Markov-state JOINT human wealth at a caller-chosen reference return (all-NaN if the Markov finite-human-wealth condition fails, with a warning). The joint solve matters: a zero-income deep-unemployment state's own-state recursion would be degenerate. - make_cFunc_slice: attaches (intercept_limit=MPCmin*hNrm, slope_limit=MPCmin) decay when both bounds are supplied, with a Carroll-Kimball (1996) concavity guard that raises on a theoretically-impossible above-line top knot (slope already fallen to MPCmin) instead of letting the decay term grow without bound. - solveConsAggShock / solve_ConsAggMarkov: optional MPCmin/hNrm parameters, default None -> legacy path; the Markov solver indexes hNrm by the current macro state. - AggShockConsumerType: MPCmin/hNrm attributes (default None) threaded through time_inv_. Computing them is deliberately left to the caller: the GE return R = Rfunc(k) is endogenous, so no single in-model R is "the" right anchor for the PF asymptote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LinearInterp's decay extrapolation approached its limiting line intercept_limit + slope_limit*x with an exponentially decaying gap A*exp(-B*(x - x_top)). For buffer-stock consumption functions -- the main users of this feature, via (MPCmin*hNrm, MPCmin) limits -- the true gap below the perfect-foresight asymptote decays as a POWER LAW, not exponentially: the exponential form vanishes far too fast above the grid, understating the gap and overstating consumption's approach to the asymptote. New opt-in kwarg decay_extrap_form: - 'exp' (default): byte-for-byte the long-standing behavior. - 'powerlaw': gap(x) = A*((x + h)/(x_top + h))**(-Q) with pivot h = intercept_limit/slope_limit (human wealth, for a consumption function) and Q = B*(x_top + h). Like the exponential, this matches BOTH the level and the slope of the interpolant at the top gridpoint, so it needs no parameters beyond the limiting line. Evaluated via exp/log1p for numerical stability; for x - x_top << x_top + h it reduces to the exponential form (which is exactly why fits over a short span above the grid cannot distinguish the two, while the tails differ materially). - 'powerlaw' validity guards (top knot strictly below the line, slope strictly above slope_limit, slope_limit > 0, positive pivot): on violation, warn and disable decay extrapolation rather than risk a divergent tail. These hold for any converged consumption function by Carroll-Kimball (1996) concavity. Adds the first tests exercising decay extrapolation (either form): pinned exponential closed form (regression), knot level+slope continuity, exact power-law tail, truth recovery on power-law knots (power law beats exponential by >100x at 100x the grid top), monotone approach from below, guard fallbacks, invalid-form ValueError, and unpickling compatibility for instances predating decay_extrap_form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
make_cFunc_slice now builds its decay-extrapolated slices with LinearInterp(decay_extrap_form='powerlaw') by default (a decay_form parameter allows 'exp'). The gap below the perfect-foresight asymptote of a buffer-stock consumption function decays polynomially, not exponentially, so the legacy exponential hands back the PF line itself where the true function is still measurably below it. Truth test (new, tests/ConsumptionSaving/test_AggShock_pf_decay.py): solve a default-calibration infinite-horizon IndShock model on a deep grid (aXtraMax=1e5), truncate its consumption function at m=40, and extrapolate with both forms. Measured: by m=2000 the exponential has destroyed ~100% of the true gap (err/gap = 1.000) while the power law keeps ~82% of it (err/gap = 0.18); max error vs truth is ~9x smaller. The module also unit-tests pf_mpc_min (closed form + return-impatience warning), pf_human_wealth_markov (analytic scalar case, joint fixed-point property, positive human wealth for a zero-income state, all-NaN on FHWC failure), and make_cFunc_slice (legacy fallbacks, power-law attach, exp override, the Carroll-Kimball concavity ValueError, and the above-line pre-asymptotic transient fallback). Adds CHANGELOG entries for the machinery and the power-law form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…amplitude New module HARK/ConsumptionSaving/pf_decay.py computes, from model primitives alone, every quantity of the power-law-decay theorem that backs the decay_extrap_form='powerlaw' tail: the eigen-root q* of E[psi^(1+q)] = Rcal*Thorn_Gamma^q, the realized exponent min(1, q*), sigma_B^2 and the closed-form amplitude B_psi (q* > 1 only), the per-rung multiplier lambda_B with a near-resonance flag, the resonance constants (C_B, c_J/Rcal), and the dual (Kesten) wealth-tail root zeta* with its existence diagnosis. GIC/RIC/FHWC violations refuse cleanly (nan/None + categorized filterable warnings, never exceptions); malformed inputs raise. Numerics are adopted verbatim from the falsified phase-1 prototype (60/60 pre-registered checks, incl. parity <= 1e-10 vs the theorem program's reference implementation and the FIGURES.md calibration table); the numpy-only bracket-expansion+bisection root-finder is kept over scipy.brentq because that exact code is what was falsified (agreement <= 5e-14 vs brentq) and it keeps the module numpy-only. Human wealth is computed from primitives in the theorem convention h = 1/(Rcal-1) (EXCLUDES current income) = h_BST - 1; the module never consumes solution.hNrm (truncated ~11% at default solve tolerance) or bilt['hNrm'] (BST convention). tests/ConsumptionSaving/test_pf_decay.py pins the HAFiscal calibration table as a permanent regression anchor (q* = 0.3813/0.6942/1.4735, B_psi = 356.63, lambda_B triple, zeta* = 9.19/3.17/None, E[ln A] triple, E[psi^2] = 1.002492 — pre-registered tolerances in the docstring) plus h-convention tripwires on BOTH HARK conventions, degenerate/refusal contracts, input acceptance, and warning-category filterability. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New keyword-only parameter decay_extrap_Q on LinearInterp. None (the default) is byte-for-byte the current behavior for both decay forms -- the new branch is dead until opted in, and no existing constructor call passes it. A positive float (powerlaw form only; ValueError otherwise) uses that EXPLICIT decay exponent instead of the 2-knot fitted one: the amplitude A stays the level gap at the top knot, so the tail is level-matched by construction, with a documented C1 kink of size (Q_fit - Q)*A/pivot in the derivative just above the top knot. Because the exponent is no longer inferred from the top-segment slope, the slope-tangency part of the powerlaw validity guard is relaxed for explicit-Q instances (only slope_limit > 0, a below-line top knot, and a positive pivot are required, NOT fitted B > 0) -- this is what later lets the AggShock attach site rescue the fallback branches where the fitted form must disable decay. Guard-violation behavior is unchanged: warn + decay_extrap = False. decay_extrap_Q_source records 'explicit' vs 'fitted' for introspection; the eval path is untouched (it already reads only decay_extrap_A/Q/pivot). tests/test_interpolation.py gains TestLinearInterpExplicitQ (7 tests, pre-registered tolerances in the class docstring): level-match + kink closed form (the phase-1 F13 mechanics, verified to 1e-9/1e-6), exact explicit-exponent tail, validation errors, decay_extrap_Q=None eval-equality with the pre-change constructor, the B <= 0 rescue case, the above-line refusal, and pickle round-trips. The 8 pre-existing TestLinearInterpDecay tests are UNCHANGED and keep passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
make_cFunc_slice gains decay_theory (a pf_decay.PowerLawDecayParams;
one object covers every Markov state and M-slice sharing the same
(R, Gamma, beta_eff, rho, psi)) and decay_Q, threaded through
solveConsAggShock / solve_ConsAggMarkov and time_inv_ exactly like the
PR threads MPCmin/hNrm (instance defaults in __init__; the Markov type
inherits both).
Default policy:
(1) decay_theory=None -> byte-for-byte the prior PR behavior
(verified bit-identically on actual micro solves of both the
AggShock and AggShockMarkov types against the pre-change HEAD,
and slice-wise against inline reconstructions of the 6c4af31
construction in the new tests);
(2) decay_theory supplied -> default decay_Q='theory': the tail
exponent is the theory value min(1, q_star), level-matched at the
top knot. The docstring documents the measured tradeoff: theory
exponents were 1.3-3.8x less accurate than the fitted tangent on
reachable windows at near-resonance calibrations, but the fitted
exponent is a noisy, grid-non-monotone 2-knot estimator that
badly understates the q*>1 amplitude (42% of B_psi at the
HAFiscal grid top) and can exceed the Prop-A0-impossible Q>1;
flipping back is the one-line decay_Q=None;
(3) decay_Q=None with theory -> theory-guarded fit: fitted exponent
clamped to the min(1, q_star) ceiling (PFDecayGridWarning when
the clamp bites; measured inert on healthy solves);
(4) decay_Q=('amplitude', B) -> closed-form boundary-value tail
B/(x+h), gated by a level-jump guard (refuses beyond 10% of the
local gap, falling back to the theory tail with a warning);
never a default;
(5) an explicit exponent rescues the fallback branches where the
fitted form must disable decay (slope_top <= MPCmin with the
knot below the line), replacing naive-linear extrapolation that
never rejoins the PF line.
Slices carry a decay_theory metadata dict (q_star, q, Q_used, Q_fit,
B_psi, lambda_B, near_resonance); at q* > 1 the fitted-vs-closed-form
amplitude ratio A*(x_top+h)/B_psi is logged once per params object.
The Carroll-Kimball above-line ValueError is untouched. The 13
pre-existing tests in test_AggShock_pf_decay.py are UNCHANGED; 15 new
tests pin every mode, the rescue, the amplitude guard, the clamp, the
nan-q* degradation, the once-per-params log, and the solver threading
(metadata reaches solved slices; explicit decay_theory=None solves
array-equal to the default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
powerlaw_tail_diagnostic (pf_decay) turns the theorem's wrong-exponent detection into a cheap solver-validation diagnostic: sweep a solved cFunc once on a log-spaced window, form the gap below the PF asymptote kappa*(m + hNrm) (hNrm = theorem-convention h*E_inc from primitives -- NEVER solution.hNrm or bilt['hNrm']), drop points under the float-cancellation guard, and locate the flat point of the compensated slope map slope(s) = s - Q_local against q = min(1, q_star). Pre-registered verdicts (thresholds in the docstring, not tuned): CONFIRMED (|slope(s=q)| <= 0.08; measured +0.046 on a fine real solve, matching the phase-1 F11 measurement), PRE_ASYMPTOTIC (local exponent below q -- the theorem-backed transient side, expected at near-resonance, where a note is appended), INCONSISTENT (flat point far from q, e.g. the h-convention trap collapsing the gap to a constant, or a local exponent steeper than the Prop-A0 impossibility floor), UNMEASURABLE (guard survivors < n_pts//2, or q_star nan). Tests exercise a real solved IndShockConsumerType at two grid resolutions (fine -> CONFIRMED; coarse -> PRE_ASYMPTOTIC, never a false INCONSISTENT), UNMEASURABLE beyond the grid, and the +E_inc h-trap on the real solve (center visibly degraded; the CLEAN INCONSISTENT signature is pinned on synthetic theorem-form gap data because at reachable windows the +E_inc constant only partially dominates a real solve's gap and honestly mimics a transient -- stated in the test docstring per the runtime-compromise rule), plus synthetic shallow/steep/trap verdicts and the refusal/plumbing paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
THEOREM-REF is this PR's convention for citing the power-law-decay theorem
program (private repo HAFiscal-Latest, theory/powerlaw-decay/): every comment
that leans on theorem material carries one pinned tag
# THEOREM-REF[HAFiscal-Latest @ <pin-sha> :: <file> :: <section heading> [:: <label>]]
# <1-3 line standalone paraphrase>
citing by heading/label text (never line numbers) at a fixed theorem-repo
commit (currently 71ca7c61), so the citation survives upstream edits and a
tool can detect when the cited section's content changes.
The tool: tools/update_theorem_refs.py (stdlib-only CLI; home = tools/,
HARK's existing repo-utility dir, cf. tools/nb_exec.py — tests/ holds
unittest suites only). --check verifies tag syntax always and resolution at
each tag's pin when a theorem-repo checkout is supplied (--repo /
$THEOREM_REF_REPO); --new-sha [--write] re-pins tags, flagging
section-body changes for human paraphrase review. On public CI without the
private theorem repo it degrades gracefully (syntax-only NOTICE, exit 0).
CI wrapper: tests/test_theorem_refs.py (2 tests; the resolution test skips
when no theorem checkout exists).
Audit results (all against the pin 71ca7c61):
- all 22 pre-existing tags resolve; paraphrases spot-checked against the
cited sections; h-convention comments verified to state the direction
correctly (theorem h = 1/(Rcal-1) EXCLUDES current income = h_BST - 1).
- 2 paraphrases tightened (pf_decay.py module header, make_cFunc_slice
guarded-fit mode): the min(1, q*) infeasibility ceiling was attributed
wholly to Prop A0, whose floor only rules out decay faster than 1/x;
the ceiling comes from the realized exponent (Theorem A1/B1).
- 9 tags added to previously untagged theorem-leaning comments (append-only;
no original text reworded): LinearInterp's decay_extrap_form powerlaw
rationale and the Carroll-Kimball validity guard (interpolation.py), the
PF-asymptote decay-target block, pf_mpc_min's mortality-as-impatience
split, pf_human_wealth_markov's excludes-current-income convention, the
make_cFunc_slice power-law-not-exponential opening and its concavity
guard (ConsAggShockModel.py), and the calibration/reachability anchor
provenance in tests/ConsumptionSaving/test_pf_decay.py.
Verification: tools/update_theorem_refs.py --check --repo <theorem checkout>
=> 31 tag(s), 0 malformed, 0 findings, exit 0; without the repo => syntax-only
NOTICE, exit 0. pytest tests/test_theorem_refs.py (2 passed),
tests/ConsumptionSaving/test_pf_decay.py + tests/test_interpolation.py +
tests/ConsumptionSaving/test_AggShock_pf_decay.py (184 passed, unchanged
tests untouched).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review pass on the power-law-decay reimport found three low-severity
correctness/documentation defects in pf_decay.py (no production/paper result is
affected — max production q* is 1.47, all zeta* well below any cap; but this code
is bound for a public HARK PR, so the diagnostics must be right). Fixes:
1. qstar_root: the fixed _BRACKET_CAP = 1024 was too small. A LEGITIMATE
calibration (GIC/RIC/FHWC all hold) with Lambda -> 0 has a finite (E)-root
q* = ln(Rcal)/Lambda that can exceed 1024; the old code then returned nan with
a hardcoded "Thorn_Gamma >= 1 ... L non-increasing -- GIC violated" diagnosis
that was false on all three counts and self-contradictory with the object's own
valid=True, and the nan poisoned the realized exponent min(1, q*) (= 1) and
dropped B_psi. Raise the shared cap to 1e12, evaluate L via logsumexp so psi
atoms > 1 cannot overflow at large q (numerically identical to the old
log(dot(...)) on production inputs), and split the no-crossing diagnosis by the
sign of the large-q slope ln(psi_max) - ln(Thorn_Gamma): "root beyond cap
(finite, astronomically near-resonance; realized exponent 1)" vs the genuine
"L non-increasing / GIC violated" case, which is preserved unchanged.
2. dual_root: same 1024 cap made it return a false None ("bracket cap hit") for a
genuine Kesten root zeta* > 1024, and the inline comment "not reachable for
finite atoms with P(A>1)>0" was demonstrably wrong. The generous cap now finds
it (zeta* ~ 8580 on the review's repro); the comment is corrected to state the
truth (f(z) ~ z*ln(A_max) -> +inf always crosses, only zeta* beyond the cap
reaches the backstop).
3. powerlaw_tail_diagnostic docstring: the pre-registered verdict semantics stated
the steeper-side INCONSISTENT threshold as center < -2*flat_tol, leaving the
band (-2*flat_tol, -flat_tol) undocumented; the code (correctly, per Prop A0)
flags any steeper-than-CONFIRMED center as INCONSISTENT. Align the docstring to
the code at -flat_tol.
Regression tests (test_pf_decay.py, +3): q* beyond the cap stays finite with
realized q = 1 and a defined B_psi and no false GIC claim; zeta* beyond the cap
stays finite and satisfies the dual eigen-equation; a local exponent in the
old-docstring dead-band is INCONSISTENT. Full pf_decay + AggShock + interpolation
+ theorem-refs suites: 189 passed (was 186; the 3 new). Production q* headlines
(0.3813 / 0.6942 / 1.4735) and B_psi = 356.63 are unchanged (logsumexp-L rewrite
is production-neutral).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_at) The surviving deliverable of the grid-placement P1 program: the placement scheme itself was KILLED by its pre-registered gates (near-kink Euler residual 2.05x default at the CTOP anchor; record in the theorem repo at theory/powerlaw-decay/grid_placement_p1_frontier_of_failure.md), but the extent criterion validated cleanly everywhere (ex-post certificates PASS at HS/CTOP/CCAP; synthetic inversion exact). rel_gap_at measures gap/c from a solved cFunc with the primitives-side h; aXtraMax_from_tail_tol inverts gap/c ~ (m+h)^-(1+q) to the certified top, with the q_eff = min(q, Q_local) pre-asymptotic amendment and the 1e-6 float64 certifiability floor. Pure primitives+measurement function (pf_decay's contract); 6 new tests; THEOREM-REF tag resolves at the pin (32/32). Suites: 195/195 PASS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four verified findings fixed: (1) MAJOR — at the near-resonance GIC-cap anchor the measured inversion under-sizes m_top (ex-post 1.8x tail_tol; the compensated amplitude is still climbing toward B_psi on any feasible window, so the relative gap decays slower than the assumed power law). Fix: a closed-form GUARANTEED route for q* > 1 — x_top = sqrt(B_psi/(MPCmin*tol)), backed by Theorem gamma-B's monotone climb of x*g(x) to B_psi (measured ex-post at CCAP: 6.9e-5 at tol 1e-4, PASS); optional B_psi/MPCmin kwargs select it. (2) rel_gap_at IndexError on empty input (scalar-unwrap branch indexed size 0). (3) TypeError (complex power) on negative safety with python floats — safety now validated finite-positive, fails closed to nan. (4) The q_eff docstring rationale was empirically backwards at shallow h-dominated coarse windows (fitted slopes are INFLATED there, the min() guard is inert and q is the binding conservative choice at q* < 1; the guard protects deep-reference solves) — rewritten, with the measured anchor verdicts (HS 0.40x, CTOP 0.98x, CCAP 1.76x->closed-form) and the one-repair formula documented. +3 tests (198 total); THEOREM-REF 33/33. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecay qstar_probe: the decay-exponent root measured numerically from the model's own one-period backward step on power-law trial gaps (normalized at the probe point, two-epsilon differenced to cancel the premium), root-finding the unit multiplier — grid-free at end-of-period assets 1e6-1e8, hence immune to sparse-knot contamination and pre-asymptopia. Matches the analytic eigen-equation root to 5.6e-6/8.6e-6/5.0e-5 on the HS/CTOP/CCAP anchors (~1e-6 depth-consistency); portable to models without a closed form via a caller-supplied one_step + PF limits; fails closed. The analytic eigen-equation is this probe evaluated on paper. mNrm_stable_points (+ StablePoints): the classical targets mNrmTrg (E[m']=m) and mNrmStE (balanced level growth) PLUS mortality-adjusted twins (R -> LivPrb*R; exact for cross-sectional mean dynamics under perpetual-youth a=0 newborns), existing under GIC-Mod-Liv/GIC-Raw-Liv even in the pure-GIC case where the unadjusted target does not (measured: the adjusted StE root hits the GIC-cap atom's neutral-measure ergodic mean to 2.5%). Returns m-roots AND their end-of-period-asset images (the grid-native coordinates: the asset grid and the consumed function live in a-space); duck-typed on any cFunc; nan-not-raise on no crossing. +7 tests (57 in test_pf_decay; suites 205 total); THEOREM-REF 36/36 resolve (two new tags pin the F2/F9 findings at 8ad5a853); CHANGELOG bullets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tile The asset-grid top = the smallest aXtraMax leaving at most eps_wealth of the (wealth-weighted, by default) stationary mass above the grid, measured from the deterministic neutral-measure stationary distribution implied by a solved cFunc (mortality + newborn reset, no RNG). dual_root gains a LivPrb argument: the mortality-augmented dual root zeta_L (LivPrb*E[(Thorn_Gamma/psi)^zeta] = 1) exists at the GIC patience cap where the classical Kesten root does not, orders with patience (9.74/4.34/1.92 on the anchors), and zeta_L <= 1 (aggregate wealth not finite) makes the wealth-measure rule REFUSE. The closed-form Pareto inversion is cover-only (measured to undershoot unsafely at large zeta_L and overshoot by orders of magnitude near 1). Protocol: coarse call + one certificate re-call on a re-solve at the returned top — load-bearing for patient types (single-call 33% short at the cap anchor; certified within 0.1-3.4% of dense truth). Per-type by construction; max over types binds a common grid. Rationale articulated in the docstring; evidence: F10 of the theorem repo's grid_design_final_spec.md + its verify_wealth_mass_rule_checks.py battery (11/11). +9 tests (66 in test_pf_decay; suites 214); THEOREM-REF 38/38. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The decay tail decoupled from the in-grid representation: at/below x_cut queries delegate to the wrapped body (any vectorized callable); above it, the limiting line minus the same decaying gap family LinearInterp builds in -- same forms, same guards, same numerically stable evaluation -- sourced from the body's level (and, for fitted forms, slope) at the cut. Byte-identical to the baked-in tails over a bare linear body (unit-gated, values and derivatives, incl. guard/disable and explicit-Q rescue paths); composes with CubicInterp (previously exponential-only, no explicit-Q hook), econforge interpolants, or bare callables (explicit-Q needs no derivative). Off-knot cuts supported (certified truncation of a solved cFunc). Keyword-only decay_extrap_A overrides the amplitude for closed- form boundary values (warns past 10% level mismatch). The 'exp' form is retained for parity but deprecated at birth here (selecting it warns); LinearInterp's own legacy default is untouched. Adversarially refuted before commit: a Python-float division raised ZeroDivisionError where the body sits exactly on the limiting line at the cut (LinearInterp's numpy arithmetic attaches B=inf / warn-disables) -- fixed by computing level_diff as np.float64, with a regression test; float32-limits and duplicated-top-knot parity edges documented as fine print. 22 new tests; suites 236 passed; THEOREM-REF 41 tags strict-clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nery
Owner design ruling (2026-07-11): the decay tail must NEVER violate level
continuity at its attachment point. Two jump-permitting hooks are removed
while the PR is still unfiled:
- DecayTailInterp loses the decay_extrap_A amplitude override (added
earlier the same day): every attachable tail is level-matched at x_cut
by construction, and the docstring states the invariant (the only
discontinuity the class can exhibit is the documented C1 derivative-only
kink of the explicit-exponent mode).
- make_cFunc_slice's decay_Q=('amplitude', B) mode -- the closed-form-
amplitude tail attached behind a 10% level-jump guard -- is removed
(the tuple form raises with guidance toward the level-matched
decay_Q=1.0). Its own record already measured the jump at order +138%
of the local gap at a pre-asymptotic top knot; the boundary value B_psi
keeps its diagnostic role in the once-per-params amplitude-ratio log.
AMPLITUDE_JUMP_TOL is retired with it.
Any future amplitude-anchored tail must be a level-matched two-term form
(asymptote-correct amplitude plus a faster-dying correction), not a jump.
Suites 235 passed; THEOREM-REF 40 tags strict-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner ruling (2026-07-11): allow one-term or two-term explicit-exponent tails, with two-term the DEFAULT, motivated explicitly as a guard against Jacobian problems in SSJ-type (sequence-space Jacobian) approaches -- policy derivatives are primitive inputs to SSJ Jacobian/fake-news construction and to differentiation through the solution, and the one-term tail's C1 kink at the attachment point makes them discontinuous for queries crossing it. decay_extrap_terms (default 2) on LinearInterp and DecayTailInterp, threaded through make_cFunc_slice and both AggShock solvers as decay_terms: gap = A*z^(-Q) + A2*z^(-(Q+1)) with the F11 closed forms A2 = G*(Q_fit - Q), A = G - A2 -- level- AND slope-matched with the theory exponent leading (C1 residual ~2e-16 measured at all theorem anchors vs one-term kinks of 1.6e-5..1.5e-4). The second exponent is Q+1, deliberately not the theory-subleading pair whose spacing |q*-1| vanishes at near-resonance calibrations. Collapses exactly to one term when the fitted rate equals the theory exponent (stored AS one-term, so the collapse is byte-identical including derivatives); warns and falls back to one term when Q_fit is not finite and strictly below Q+1 (a non-finite rate -- degenerate top segment, infinite pivot -- previously attached a silent NaN tail: adversarial-refuter finding, fixed with regression). decay_terms is validated on every path including the legacy early return (refuter finding). Fitted forms are inherently slope-matched, unaffected; one-term regressions pinned via decay_extrap_terms=1. Theorem-repo battery extended D6-D9 (C1, closed-form exactness, accuracy band, properties/collapse): 24/24; two-term accuracy at the HS handoff measured 2.3e-4 vs the one-term 6e-4 band. Suites 249 passed; THEOREM-REF 44 tags strict-clean (four new tags pin F11). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f-Moderation coordinates (tail-only) + enriched guard-trip diagnosis - DecayTailInterp gains a third tail family: chi(mu) = chi_cut + Q*u + (chip_cut - Q)*(1 - e^-u) in mu = ln(x - x_min), omega recovered via the stable expm1 form. Level- and slope-matched (C1) at ANY cut with no guard; gap strictly inside (0, slope_limit*hEx) by construction; same asymptotic power law gap ~ mEx^-Q; exact collapse to the pinned chi-line at chip_cut == Q. Requires explicit decay_extrap_Q and new keyword x_min (the pessimist bound); moderation-premise violations raise (inconsistent inputs), unlike the fitted/explicit guards which warn-and-disable. - Two-term guard-trip warnings (LinearInterp + DecayTailInterp) now name cause and remedies (extend grid toward Q*hNrm, or moderation_tail); with x_min supplied the DecayTailInterp warning prints the exact steepness decomposition Q_fit = s_mu*(1 + hEx/mEx) and the guard-safe boundary mEx > Q*hEx. - 11 new tests (C1 incl. guard-tripped and widening-gap cuts, strict bounds, asymptotic exponent, exact local-exponent identity, collapse, guard-free vs one-term fallback kink with closed-form match, refuse paths, opt-in neutrality, FD derivative, deep-query saturation without warnings, pickle roundtrip); suite 166/166. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The power-law-decay theory corpus moved 2026-07-13, history preserved, from the private HAFiscal-Latest theorem branch to llorracc/BufferStockTheory-Latest (branch 20260713-powerlaw-decay-theory-port, merge aa935570). All 45 pins are rewritten to the new repo with content-verified sha equivalents (tree hashes of theory/powerlaw-decay identical old vs new in every case): HAFiscal-Latest 367b9b34 -> BufferStockTheory-Latest 3f4b021e HAFiscal-Latest 71ca7c61 -> BufferStockTheory-Latest c181870f HAFiscal-Latest 8ad5a853 -> BufferStockTheory-Latest 0d1f88d8 HAFiscal-Latest d67f9183 -> BufferStockTheory-Latest 716cfd82 Pins into the published six-document manifest now also carry the public page URL (readable without repo access): https://llorracc.github.io/BufferStockTheory-Latest/powerlaw-decay-theory/ Old pins remain resolvable forever via tag powerlaw-theory-final-pre-port (HAFiscal-Latest @ 8ff59ba5); the new shas are protected by tag powerlaw-theory-port-2026-07-13 (BufferStockTheory-Latest @ aa935570). No code change: comments only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bottom-end member of the DecayTailInterp family: delegates at/above a knot, and below it follows c = MPCmax*me - K*me**(1+CRRA) (me = m - mNrmMin), value-matched at the knot -- MPC rises to MPCmax at the constraint instead of the EGM bottom secant's biased slope. strict=True enforces the Theorem CE regime at the knot and raises with the st-cor-C4 grid-rule message; strict=False admits the bootstrap corridor |K|*me**CRRA < MPCmax; try_make is the in-solve guarded constructor with the MPC-in-(0, MPCmax] exposure gate layered on the corridor (returns None; caller keeps its default assembly). THEOREM-REF pins @ BufferStockTheory-Latest 12b0b178 (st-thm-CE, st-thm-CE-psi, st-prop-C1-psi, st-cor-C4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tail_tol Constraint-end theory helpers for the kappa_bar bottom tail: * ce_psi_regime(IncShkDstn, CRRA, Thorn_Gamma): Theorem CE-psi regime classifier -- regime I iff lambda(psi_min) = p_eff**(1/CRRA)*Thorn_Gamma/psi_min < 1, with p_eff the worst-JOINT-atom mass (= HARK's WorstIncPrb accounting); regime II / undetermined carry the st-rem-CE-regime anchor. New filterable ConstraintEndRegimeWarning in the module's warning taxonomy. * aXtraMin_from_tail_tol: the bottom-end mirror of aXtraMax_from_tail_tol (st-cor-C4 / st-eq-C4): inverts the Theorem CE deviation law from one measured reference node to the aXtraMin at which the bottom knot reads the constraint asymptote to a target relative tolerance, with the m-to-a-space conversion aXtraMin = (1 - kap_bar)*me_target and a documented ex-post certificate. Fail-closed nan returns; tail_tol clamped at 1e-6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New agent-level options in the decay_extrap_form naming family (defaults
None = byte-for-byte the legacy solve):
* decay_extrap_form in {None, 'powerlaw'}: top tail above the grid, threaded
through LinearInterp's explicit-Q powerlaw machinery on the linear path and
a DecayTailInterp wrap (x_cut = the top EGM knot -- required, the assembled
cFunc is a LowerEnvelope with no x_list) on the CubicBool path. The
exponent min(1, q_star) is computed once pre-solve at the agent level from
pf_decay.powerlaw_decay_params_from_agent (validity-gated; pf_decay's
near-resonance warning practice surfaces there), refreshed each solve;
explicit decay_extrap_Q overrides.
* decay_extrap_form_lower in {None, 'kappabar'}: Theorem CE constraint-end
tail below the first EGM gridpoint via KappaBarTailInterp with the solver's
own analytic MPCmaxUnc (the Prop C2 recursion), guarded three ways:
natural-constraint branch only; the Theorem CE-psi regime gate (regime II
or undetermined warns ConstraintEndRegimeWarning naming st-rem-CE-regime
and keeps the default secant); and try_make's corridor + MPC-range
exposure gate per backward step.
Both tails attach to cFuncNow BEFORE vPfuncNow = MargValueFuncCRRA(cFuncNow)
is built, so the next backward step's Euler expectation (calc_vp_next
overrunning the grid at large transitory draws / the worst-income branch)
integrates over the tails too -- one wiring point serves the in-solve role
and the returned-policy role at once.
Delivery is deliberately signature-safe: the tails live in the new
solve_one_period_ConsIndShock_with_tails, and the public
solve_one_period_ConsIndShock keeps its EXACT legacy argument list,
delegating with the options off. solve_one_cycle demands every named solver
argument from the agent, so growing the shared signature breaks re-solve
paths that skip pre_solve (measured: the HANK Jacobian machinery's
solve(presolve=False) in ConsNewKeynesianModel raised KeyError on the new
names). pre_solve swaps the _with_tails solver in -- and registers the
option names in time_inv, the vFuncBool/CubicBool delivery pattern -- only
when an option is enabled, restoring the stock solver when both are None.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…powerlaw_extrap.py Nested-grid fidelity protocol (truth = tails-in-solve on a wide dense log a-grid; small solves on strict subsets grid[k:-k]; rails / tails / tails-eval-only variants; G-AUD-style rails-on-extended-grid non-circularity audits whose agreement is the printed measurement floor below which no ratio is gated). Two calibrations: CE-rho2 (psi==1, zero-income atom -- the Theorem CE bottom story) and CAL-HS (psi-general -- the eq-powerlaw top story plus the Theorem CE-psi regime-I bottom with mNrmMin < 0). Gates pre-registered with measured headroom (values in the module docstring): CE bottom 1e-8 (measured 4.77e-11, floor 2.53e-11), CE top 1e-6 (2.45e-08), HS top 1e-6 (2.28e-08, floor 2.96e-10), HS bottom 2e-4 (9.51e-06), MPC approach and in-grid contamination gates, two-roles assertions (in-solve strictly better than eval-only: CE bottom ~353x, HS top ~2.4x), 2-point ladder monotonicity, determinism (byte-identical re-solve), the regime-II warning + refusal test, the artificial-constraint refusal test, the cubic path, the public-solver signature freeze (the presolve=False regression guard), unit tests for KappaBarTailInterp / ce_psi_regime / aXtraMin_from_tail_tol (with the ex-post certificate exercised end-to-end), and the default-None byte-zero regression pinned against parent df50847. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y constraint-end helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MAJOR-1 of the adversarial review: the byte-zero pin values were generated on linux/x86-64, and HARK CI also runs macOS/Windows where libm last-ulp differences legitimately shift solved values -- exact float equality is now asserted only on sys.platform == 'linux' and machine == 'x86_64', degrading to np.allclose(rtol=1e-9) elsewhere. The in-process explicit-None-equals-stock test remains exact on every platform (it is the platform-free byte-zero guarantee). MINOR-3: the module docstring overclaimed '>= 20x headroom' for EVERY gate; it now states the error gates' >= 21x and the two-roles FACTOR gates' actual margins, and GATE_HS_TWO_ROLES_TOP is lowered 1.5 -> 1.3 for platform robustness (measured 2.42x on linux/x86-64). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… solve MINOR-1 of the adversarial review (reproduced there: auto Q, user assigns 0.9, re-solve reset it to auto). _setup_decay_extrap now REMEMBERS the last auto-computed exponent (_decay_extrap_Q_auto_value) and treats decay_extrap_Q as auto-computed only while it is None or still equals that remembered value: parameter changes keep refreshing a still-auto exponent, while an explicit user value -- including one assigned after an auto solve -- is validated and respected, never recomputed over. Disabling the form clears a still-auto Q as before; a leftover user Q with the form disabled still raises. Docstring's 'left untouched' claim updated to the actual semantics; regression test covers all four paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…q-wbar-def pins MINOR-2 (docs only, no behavior change): the option docs now state that the tails are not yet supported with ConsNewKeynesianModel.calc_jacobian -- its ghost agents solve with presolve=False, so a tails-enabled steady state would mix with railed transition solves; keep the options at their None defaults there. NIT-3: the lifecycle caveat (the automatic exponent uses the t=0 primitives; time-varying calibrations wanting period-specific exponents should pass an explicit decay_extrap_Q) now lives in the user-facing decay_extrap_Q docstring, not only in _setup_decay_extrap's. MINOR-5: the two prompt-listed pins that were absent are added in the same 4-field syntax-valid format @ 12b0b178: st-prop-C2 attached to the kappa_bar_t recursion content next to the st-prop-C1-psi pin in KappaBarTailInterp (interpolation.py), and eq-wbar-def attached where the h/wbar convention is stated in the new solver docstring (the cbar = MPCmin*(m + hNrm) == kappa*(m - 1 + h_BST) bridge). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical reformat only (tests/ConsumptionSaving/test_powerlaw_extrap.py); no code change. interpolation.py and pf_decay.py are deliberately NOT reformatted: they were already nonconforming to ruff-format at the parent commit (the #1782 branch's 79-column house style), and this branch's additions match their in-file style -- reformatting them wholesale would bury the PR diff in unrelated mechanical churn. ruff check is clean on all touched files; ConsIndShockModel.py (format-clean at the parent) remains format-clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wner: fold into the open PR rather than a stacked one)
…#1782) Self-contained mission brief for the next increment (executor assumed to have no prior knowledge): (A) refine the bottom-tail gate to activate whenever the NATURAL borrowing constraint binds - i.e. BoroCnstArt is None OR BoroCnstNat >= BoroCnstArt, per-period for lifecycle - refusing only when the artificial constraint strictly binds (MPC-kink theory reason documented); (B) implement pf_decay.powerlaw_validity_threshold from the proofs' analytic x0 (stage_A (5.0a); deliberately crude, diagnostic-only); (C) an illustrative notebook at examples/ConsumptionSaving/PowerlawExtrapolation.ipynb: College-TOP preferences + zero-income atom (pinned sigmas: q* = 0.6727), the HARD-WIRED ex-ante grid recipe aXtraMax = 2*h_BST with tail_tol reported EX POST as the where-the-extrapolation-begins diagnostic (measured 9.4% at 2h; explicitly NOT a perfect-foresight handoff), three grids vs one truth incl. a tooling-diagnosed failure case and a validity-threshold grid, the fidelity experiment above and below, MPC-limit panels, the gate demo, and public links to the theory pages. PENDING owner release; commits fold into this PR. (File force-added past the machine-local prompts_local exclude, per owner instruction - prompts are provenance.)
…rapolation notebook The notebook-and-binding-gate workstream on PR #1782. - pf_decay.powerlaw_validity_threshold (+ ValidityThreshold): the proofs' explicit guaranteed-validity floor wbar0 in the wbar = m + hNrm coordinate, transcribed from stage_A_proof.md displays (5.0a)/(5.0) (the K-hat-free x0^0 and the full x0 = max{x0^0, 2*K-hat}). A DIAGNOSTIC, never a refusal gate; fails closed to nan under GIC/FHWC/RIC violations. THEOREM-REF-pinned; hand-computed unit test. - Constraint-end gate: the landed `BoroCnstNat < mNrmMin` refusal is already equivalent to "attach when the natural constraint binds" (BoroCnstArt is None or BoroCnstNat >= BoroCnstArt) -- a slack artificial constraint does NOT block the tail; only a strictly-binding one does. No logic change; the misleading "natural-constraint branch only" wording is corrected and two regression tests added (artificial-but-slack builds the tail; a lifecycle whose per-age natural constraint crosses a fixed artificial one flips the gate across ages). - examples/ConsumptionSaving/PowerlawExtrapolation.ipynb: executed illustrative notebook on the College-TOP calibration -- derived theory quantities, the tolerance<->grid-top table, three grid configs vs a dense truth, the top/bottom fidelity experiment, the binding-constraint gate demo, and the two-roles coda. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Move PowerlawExtrapolation.ipynb from examples/ConsumptionSaving/ (a directory that did not previously exist) to examples/ConsIndShockModel/, matching the per-model-class examples convention (IndShockConsumerType lives in ConsIndShockModel.py, alongside three sibling example notebooks there). - Compare the two REAL top-extrapolation rules -- HARK's default exp-decay vs the power-law tail -- instead of a straw-man discontinuous "PF-line above a cutoff" rule nobody uses; state the error measure (sup relative error in c vs a dense truth solve) explicitly. - Grid top at human wealth (G2 = hNrm), with G3 = 4 hNrm and G4 = the guaranteed floor: the in-grid interior is identical across hNrm / 4 hNrm / 23 hNrm, so 4x human wealth is not meaningfully better than 1x for the policy actually used. - Add an in-grid (interior) fidelity figure: a too-short grid corrupts the interior through the solver's own expectations; a human-wealth grid fixes it. - Expand the exposition (perfect-foresight rule, MPC, the gap, the two laws) and add inline theory references at each assertion rather than one link at the end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… postmortem (imported core) The ergodic-grid-diagnostics increment on PR #1782. Theory of record: BufferStockTheory-Latest ergodic_coverage.md; the numeric core is IMPORTED, not mirrored (owner ruling: one source of truth, no bespoke variants). - pf_decay.ergodic_grid_diagnostics / _from_agent (Task A): the ex-ante patience screen, a thin adapter over BST-Latest's ergodic_coverage_lib -- soft-imported at CALL time (never at HARK import time), API_LEVEL-guarded, zero duplicated numerics. The ergodic m-distribution's tail exponent is pinned by patience primitives (a Kesten random-growth process): survivor / agent-counting / Harmenberg roots on the model's OWN psi atoms via the mortality-EFFECTIVE ladder (beta_eff = DiscFac*LivPrb, not pre-multiplied). Reproduces the triple-confirmed pins (alpha 2.7078 / 3.8715 / 5.5288, raw-beta shadow 0.4666, and the raw-vs-effective GIC-Nrm split that makes College-TOP's tameness mortality-financed). - pf_decay.ergodic_grid_report (Task B): a HARK-native postmortem on a deepcopy -- it never mutates, re-grids, or re-solves the user's agent -- with a fixed-seed agent.simulate() panel, the imported Hill estimator, and a coverage-vs-accuracy pairing via rel_gap_at at the top knot (mNrmStE/mNrmTrg 3.4946 / 3.7063). - examples/ConsIndShockModel/PowerlawExtrapolation.ipynb (Task C): a new "grid coverage vs grid accuracy: the ex-ante patience screen" section -- coverage (~20) is satisfied far below the accuracy scale (~human wealth), so accuracy binds. Runs live where a BST-Latest checkout is present, dormant (guarded, no error) otherwise. - tests (Task D): root / ladder / bounded-regime / stable-point pins, report determinism, and the API_LEVEL drift guard; every ergodic test skips cleanly when ergodic_coverage_lib is unimportable (public-CI dormancy). Passive diagnostics only: advisory ErgodicCoverageWarning, no auto-regrid, byte-zero defaults. The ergodic core is a soft optional dependency, so HARK import and public CI are unaffected (the feature is dormant where the theory checkout is absent). Full tests/ConsumptionSaving/ suite: 421 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…> local_q_diag) The measured-Q increment on PR #1782: LinearInterp already accepts the explicit exponent (decay_extrap_Q); this adds the measurement's travelling diagnostics so the two-secant workflow round-trips through HARK. - LinearInterp gains keyword-only q_diagnostics (default None), stashed verbatim as self.local_q_diag before any decay validation: present on every construction path (fitted, explicit-Q, guard-disable), never consulted by evaluation. Convention: (Q1, Q2, drift) = the two log-log secant exponents of the gap in the shifted abscissa x + h over the top three knots plus the drift advisory, with decay_extrap_Q=Q2 (the most local secant). Both spellings are API: they mirror the downstream reference implementation (HAFiscal's PowerLawDecayLinearInterp) as its drop-in contract, so post-solve tools read the measurement off converged slices via getattr(slice, "local_q_diag", None). - tests: TestLinearInterpMeasuredQ, the HARK-scale mirror of the HAFiscal (e) family (test_pf_asymptote_decay.py:: test_powerlaw_flag_value_attaches_powerlaw_form) against this file's synthetic power-law truth, out to 75x the grid top (the certification depth): two-secant recovery of the exact fixture exponent (bound 1e-9; measured 3e-12), rider attach/round-trip + evaluation-neutrality + guard-disable survival, one-term tail == the reference analytic form (bound 1e-12 abs; measured exact, value and derivative), strictly below-the-line + powerlaw-holds-the-gap-the-exponential-destroys (gap ratio > 0.99 vs < 0.1 at ~5x the grid top) + in-sample identity, and far-field truth tracking (two-term <= 3e-5 rel, measured 3.2e-6; the exponential's worst error >= 10x larger, measured 65x). - CHANGELOG: q_diagnostics bullet next to the decay_extrap_Q entry. tests/test_interpolation.py: 171 passed (166 existing + 5 new); tests/ConsumptionSaving/test_AggShock_pf_decay.py: 27 passed. ruff 0.11.8 check clean; the new lines are format-stable under the pinned ruff-format (the files' pre-existing format drift is untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # docs/CHANGELOG.md
alanlujan91
left a comment
There was a problem hiding this comment.
Chris - I went through this systematically: all four production files, the test suite, and the ref-tooling. The engineering discipline is genuinely impressive in places - the byte-exact backward-compatibility pins, the eager construction-time option validation (a typo'd decay_extrap_form can never silently mean None), and the centralized warning pattern in ergodic_grid_report are better than most of what's in HARK today. But the review surfaced one outright bug that blocks the AggShock half, several silent-failure paths, and a few structural issues. Ranked, with details in inline comments:
Blocking
- The AggShock power-law tail's intercept is expressed in the wrong coordinate - exactly zero on every default calibration, so no existing test can see it, and it contaminates the guard meant to catch wrong references (inline at
ConsAggShockModel.py). time_inv_gains ten names unconditionally, so out-of-treeAggShockConsumerTypesubclasses that don't route through your__init__now hitKeyErroron solve (inline).- The AggShock theory branch uses
decay_theory.qwithout checking.valid, while the IndShock path raises in the same situation (inline). - The tail diagnostic's one-sided INCONSISTENT band is wrong when q* < 1 (inline at
pf_decay.py). - The kappabar bottom tail can silently fail to attach all the way to convergence, with no signal (inline at
ConsIndShockModel.py). - The grid-sizing functions - your own "operative quality rule" - return bare nan on five distinct failure modes, and the
tail_tolclamp silently certifies a looser target than the user requested (inline).
Important
7. The solver swap living in pre_solve means the new options are silently inert on every subclass that overrides pre_solve without super() - roughly a dozen in-tree - while those subclasses still accept and echo the parameters. Same root cause as the Jacobian/SSJ path concern. Plus the auto-computed exponent uses t=0 primitives with no time-variation check on lifecycle calibrations (inline).
8. Test-calibration gap: every q* < 1 calibration in the suite gets there via patience with sigma_psi^2 = 0.003; there is no solved-model test of variance-driven q* < 1 (E[psi^2] > R*PhiTilde), which is the regime where the verdict band and grid-fit behavior differ qualitatively. And no AggShock test runs with IncUnemp > 0 - the single test that would have caught blocking item 1 (inline).
9. The THEOREM-REF tooling is out of sync with its own tags: running tools/update_theorem_refs.py --check on this branch yields 35 of 61 tags MALFORMED (5-field tags against a max-4 grammar), so test_check_without_theorem_repo_degrades_gracefully should be failing on this branch as shipped. The tool's own defaults also name HAFiscal-Latest while every tag says BufferStockTheory-Latest (inline).
10. .agents/prompts_local/... is an AI work-order document with machine-specific paths and shouldn't ship in a library's history (inline).
Smaller, cheap fixes: pf_mpc_min warns on RIC violation but returns a non-positive MPCmin that make_cFunc_slice never refuses (needs an explicit if not (MPCmin > 0): return LinearInterp(...)); the amplitude-ratio diagnostic goes through logging.info, which is invisible under Python's default logging config, contradicting its own "visible in production logs" docstring (ConsAggShockModel.py:508); none of the five new warnings.warn calls pass stacklevel=, so they all report HARK-internal lines; tol = 1e-8 * max(1, |c_top|) gates level_diff, a quantity that legitimately vanishes on well-extended grids, silently reverting to naive-linear extrapolation exactly when the user sizes their grid correctly - this wants a relative criterion; and GATE_HS_TWO_ROLES_TOP was lowered on the CI platform itself without a cross-platform measurement motivating it.
Two design questions rather than findings: whether HARK is the right home for ~60 citations resolvable only against a private repo (public CI validates syntax but never resolution, so rot is invisible to every contributor without your checkout); and whether the AggShock option surface - MPCmin/hNrm as agent parameters colliding with ConsumerSolution field names that carry the other h-convention - should be reworked before it hardens into API.
Several of these are one-line fixes, and the blocking item has a clean per-slice fix (pass hNrm_i + BoroCnstNat_vec[n] inside the slice loop). Happy to go through any of it on a call.
| @@ -307,7 +754,13 @@ def vPnextFunc(S, a, M): | |||
| for j in range(Mcount): | |||
| c_temp = np.insert(cNrmNow[:, j], 0, 0.0) # Add point at bottom | |||
| m_temp = np.insert(mNrmNow[:, j] - BoroCnstNat_vec[j], 0, 0.0) | |||
There was a problem hiding this comment.
Blocking: this slice is built in the shifted coordinate x = m - BoroCnstNat (undone at evaluation by VariableLowerBoundFunc2D), but make_cFunc_slice sets intercept_limit = MPCmin * hNrm, so the attached tail decays toward MPCmin*(m - BoroCnstNat + hNrm) instead of the PF asymptote MPCmin*(m + hNrm). The correct intercept in this coordinate is MPCmin*(hNrm + BoroCnstNat_vec[j]).
Why nothing catches it: with HARK's default IncUnemp = 0 the worst transitory draw is zero, so aNrmMin collapses to 0 and BoroCnstNat_vec is identically zero - the bug is exactly zero on every default calibration and every test built from one. With unemployment insurance on (IncUnemp > 0), aNrmMin < 0 and the tail's PF line sits too high by MPCmin*|aNrmMin| across the whole extrapolated region. Worse, the guard is contaminated too: level_diff is overstated, so the Carroll-Kimball raise under-fires and healthy over-fires, and Q_fit is biased in both factors - the check meant to catch a wrong MPCmin/hNrm reference is built on the wrong reference.
In solve_ConsAggMarkov a scalar intercept computed outside the slice loop can't be right at all (BoroCnstNat varies within the loop); the fix has to move inside: pass hNrm_i + BoroCnstNat_vec[n] per slice. A regression test at IncUnemp > 0 asserting the extrapolated cFunc approaches MPCmin*(m + hNrm) in m-space would have caught this and should accompany the fix.
| time_inv_ = IndShockConsumerType.time_inv_.copy() | ||
| time_inv_ += ["Mgrid", "AFunc", "Rfunc", "wFunc", "PermGroFacAgg"] | ||
| time_inv_ += ["Mgrid", "AFunc", "Rfunc", "wFunc", "PermGroFacAgg", "MPCmin", "hNrm"] | ||
| time_inv_ += ["decay_theory", "decay_Q", "decay_terms"] |
There was a problem hiding this comment.
Backward-compat break: solve_one_cycle builds its solve dict via a hard agent.__dict__[parameter] subscript over time_inv, so any out-of-tree AggShockConsumerType subclass whose __init__ doesn't route through yours now raises KeyError: 'MPCmin' on solve. The in-repo __init__ guard only covers instances that go through it. This contradicts the byte-for-byte-unchanged framing for anything outside the HARK tree (REMARKs, DemARKs, user code). Consider defaulting these attributes at class level or guarding their inclusion.
| ) | ||
| elif decay_Q is None: | ||
| mode = "guarded_fit" if decay_theory is not None else "legacy" | ||
| elif isinstance(decay_Q, str) and decay_Q == "theory": |
There was a problem hiding this comment.
This branch checks np.isfinite(decay_theory.q) but never decay_theory.valid. Per pf_decay's own docs, a GIC-violated calibration reports valid=False with a finite (E)-root that is explicitly outside the theorem's scope - and this code will use it as the tail exponent for the whole solve. The IndShock path (_setup_decay_extrap) raises in exactly this situation; the asymmetry looks unintentional. Suggest gating on .valid here the same way, or re-emitting the recorded condition warnings once per slice build. (The healthy-case-only test coverage in test_AggShock_pf_decay.py means a GIC-violated decay_theory fed to this path is currently untested.)
| notes: str | ||
|
|
||
|
|
||
| def powerlaw_tail_diagnostic(cFunc, MPCmin, hNrm, params, m_lo=None, m_hi=None, |
There was a problem hiding this comment.
The one-sided verdict band conflates two different ceilings. Prop A0 forbids a gap fading faster than 1/x, i.e. local exponents above 1. But the INCONSISTENT branch triggers on any local exponent steeper than min(1, q*) - and when q* < 1, the region (q*, 1] is not forbidden by A0 and is exactly where a true solution's local exponent sits below the floor-to-tail crossover (the 1/x forced mode dominates until the suppressed x^{-q*} mode's amplitude wins). In my own experiments a correct solve in that regime shows local exponents near 1 over the entire practical range - which this band would flag INCONSISTENT ("broken grid / wrong reference / non-converged") on a healthy solution. The claim "migration toward min(1, q*) only ever comes from BELOW" holds for the patience-driven q* < 1 calibrations in your test suite (HS-type, where I can confirm the local exponent sits below q* and the diagnostic verdicts are correct), but not in the variance-driven regime. Suggest: make the steep side of the band conditional on q* >= 1 (where it genuinely coincides with A0), or downgrade steep-side hits to PRE_ASYMPTOTIC when q* < 1 with a note about the crossover scale.
| ConstraintEndRegimeWarning, | ||
| ) | ||
| else: | ||
| kappabar_tail = KappaBarTailInterp.try_make( |
There was a problem hiding this comment.
Silent no-op: try_make deliberately never raises (right call for transient iterates), but this consumer has no else branch, no counter, and writes nothing to the solution recording whether the tail attached. If the corridor/exposure gate keeps failing through convergence - e.g. the grid bottom is too coarse, exactly the condition aXtraMin_from_tail_tol exists to fix - the final solution silently uses the plain EGM secant, and a user who requested decay_extrap_form_lower='kappabar' can only discover it via isinstance. The coarse ce_psi_regime gate one level up warns properly (ConstraintEndRegimeWarning), so the pattern exists; this finer gate just doesn't use it. Suggest tracking attachment on the final solve and warning (or exposing a bilt-style field) when the requested tail never took effect.
| return float(out[0]) if out.size == 1 else out | ||
|
|
||
|
|
||
| def aXtraMax_from_tail_tol(m_ref, rel_gap_ref, q_eff, hNrm, tail_tol, |
There was a problem hiding this comment.
Two silent paths in the module's own "operative ex-post quality rule": (1) five distinct failure modes (non-finite/non-positive inputs) all collapse to a bare float('nan') - unlike qstar_root/dual_root, which return (value, reason). These functions feed user grid construction directly, so the nan surfaces far downstream with an error that doesn't point back here. Suggest the (value, reason) convention or a warning on the failure branch. (2) The tail_tol clamp to the 1e-6 certifiable floor is well-motivated but fires silently: a user requesting a grid certified to 1e-10 receives the materially smaller 1e-6 grid top, labeled as certified, with no signal they were downgraded. One PFDecayGridWarning when the clamp changes the value fixes it.
| self.check_conditions(verbose=self.verbose) | ||
| self._setup_decay_extrap() | ||
|
|
||
| def _setup_decay_extrap(self): |
There was a problem hiding this comment.
Two issues rooted in the swap living in pre_solve: (1) every subclass that overrides pre_solve without calling super() inherits the three decay_extrap_* parameters via the defaults dict, accepts them in assign_parameters, echoes them in describe_parameters - and does nothing. The raise guard fires only for subclasses that inherit pre_solve, i.e. exactly the ones that already work. Roughly a dozen in-tree types are affected; the options are silently inert there. (2) The auto-computed exponent uses t=0 primitives, as the docstring notes, but there's no runtime check for time-varying Rfree/PermGroFac/LivPrb/shock processes - a lifecycle user gets a single age-0 exponent baked into every period's Euler recursion with no warning. Suggest moving the swap (or at least a validity check) into the solver-construction path, and warning when auto-computing on a calibration with time-varying primitives.
| # q_star = 0.3759 here vs 0.3813 on the reference stack's atom grid). Its | ||
| # worst JOINT atom is the lowest employed-income atom (theta_min < IncUnemp), | ||
| # giving lambda(psi_min) deep in regime I (statement.md st-rem-CE-regime). | ||
| HS_PARS = dict( |
There was a problem hiding this comment.
Calibration-space gap: every q* < 1 calibration in the suite (HS here, CTOP/CCAP in test_pf_decay.py) reaches it via patience, with sigma_psi^2 capped at 0.003 throughout; the one large-Var(psi) distribution in the suite is used only for bottom-regime classification. There is no solved-model test of variance-driven q* < 1 (E[psi^2] > R*PhiTilde), which is a qualitatively different mechanism - the mode lives on the discretized psi lattice and the reachable-grid behavior differs (see my comment on the verdict band in pf_decay.py). One fat-psi calibration exercised through the full top-tail fit would close it. Separately: an AggShock regression at IncUnemp > 0 would have caught the intercept-coordinate bug flagged in ConsAggShockModel.py - as it stands, every AggShock test runs where the bug is identically zero.
| "section [:: label]), got %d" % len(parts)) | ||
| if len(parts) > 4: | ||
| return Malformed(path, lineno, | ||
| "too many '::' fields (%d); max is 4" % len(parts)) |
There was a problem hiding this comment.
I ran this tool's --check against the tags actually shipped in this branch: field-count distribution {5: 35, 4: 25, 3: 1} - 35 of 61 tags MALFORMED under this max-4 grammar, exit 1. Most tags append a 5th ::-separated field (the GitHub Pages URL), which the grammar doesn't admit. So test_check_without_theorem_repo_degrades_gracefully, which asserts "0 malformed" on the no-repo path public CI runs, should be failing on this branch as shipped. Two more sync issues in the same apparatus: the tool's docstring and the test's default THEOREM_REPO path name HAFiscal-Latest (a worktree path) while every tag and URL in the codebase says BufferStockTheory-Latest - one of them is a generation behind; and the test docstring says "the PR landed 22 [tags]" with MIN_EXPECTED_TAGS = 20, while the actual count is 61. The rot-prevention tooling is currently out of sync with more than half of its own subject matter, which is worth fixing before merge precisely because this system is the thing that's supposed to prevent that.
| @@ -0,0 +1,241 @@ | |||
| # PROMPT (HARK repo): the powerlaw-extrapolation illustrative notebook + the binding-constraint gate refinement | |||
There was a problem hiding this comment.
This is an AI work-order/coordination document - status metadata ("PENDING owner release"), machine-specific paths (/home/shared/github/...), and an explicit "Audience: an AI" header. It doesn't belong in a library's permanent git history. If the substantive technical content (the theory background, the what-already-exists inventory) is worth keeping, a stripped-down design note under docs/ or the PR description is the right home; this file should be dropped from the PR either way.
There was a problem hiding this comment.
Pull request overview
Adds opt-in, theory-backed power-law extrapolation for buffer-stock consumption functions, related diagnostics, and grid-sizing utilities.
Changes:
- Adds composable upper and lower tail interpolators.
- Integrates optional tails into individual and aggregate-shock solvers.
- Adds theory utilities, diagnostics, documentation, and extensive tests.
Reviewed changes
Copilot reviewed 11 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
.agents/prompts_local/20260715-0900h_prompt_notebook-and-binding-gate.md |
Records implementation requirements. |
HARK/ConsumptionSaving/ConsAggShockModel.py |
Adds aggregate-shock PF tails. |
HARK/ConsumptionSaving/ConsIndShockModel.py |
Adds opt-in individual-shock tails. |
HARK/ConsumptionSaving/pf_decay.py |
Implements theory and diagnostics. |
HARK/interpolation.py |
Adds power-law tail interpolators. |
docs/CHANGELOG.md |
Documents new APIs. |
examples/ConsIndShockModel/PowerlawExtrapolation.ipynb |
Demonstrates tail behavior. |
tests/ConsumptionSaving/test_AggShock_pf_decay.py |
Tests aggregate-shock integration. |
tests/ConsumptionSaving/test_pf_decay.py |
Tests theory utilities. |
tests/ConsumptionSaving/test_powerlaw_extrap.py |
Tests solver-tail fidelity. |
tests/test_interpolation.py |
Tests interpolation behavior. |
tests/test_theorem_refs.py |
Tests citation checking. |
tools/update_theorem_refs.py |
Verifies and updates theorem references. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| x_top = np.sqrt(B_psi / (MPCmin * tail_tol)) | ||
| return float(x_top - hNrm) |
| E_inc = float((p_j * psi_j * th_j).sum()) | ||
| E_prod = float((psi_p * psi_a).sum()) * float((th_p * th_a).sum()) | ||
| if abs(E_inc - E_prod) > 1e-10 * max(1.0, abs(E_inc)): | ||
| _record( | ||
| f"IncShkDstn psi and theta are correlated " | ||
| f"(|E[psi*theta]-E[psi]E[theta]| = {abs(E_inc - E_prod):.2e}); " | ||
| f"the theorem's hypotheses assume psi independent of theta. " | ||
| f"sigma_B2 is the exact Var(W) on the joint; q_star and " | ||
| f"zeta_star use the psi marginal.", ShockCorrelationWarning) |
| psi_a, psi_p = np.array([1.0]), np.array([1.0]) | ||
| else: | ||
| psi_a, psi_p = _as_atoms_probs(PermShkDstn, "PermShkDstn") | ||
| th_a, th_p = _as_atoms_probs(TranShkDstn, "TranShkDstn") |
| hh = powerlaw_decay_params_from_agent(agent, warn=False).h | ||
| if np.isfinite(top_knot): | ||
| rg = float(np.atleast_1d( | ||
| rel_gap_at(sol.cFunc, np.array([top_knot]), diag.MPCmin_eff, hh))[0]) |
| - Adds `pf_decay.powerlaw_validity_threshold` (+ `ValidityThreshold`): the proofs' EXPLICIT guaranteed-validity floor `wbar0` for the top gap law, computed from primitives, in the total-wealth coordinate `wbar = m + hNrm`. Returns both the K̂-free `wbar0_hat_free` (display (5.0a): `max{ h+mbar, (8(rho+1)*gbar/kappa + C0)/Thorn_Gamma, 8(rho+1)*C0/Thorn_Gamma, (h+1+C0)/Thorn_Gamma, 2*zeta }`) and the full `wbar0` (display (5.0): `max{wbar0_hat_free, 2*K̂}`, `K̂ := 2(K_L+K_R)`, Cor. 5.2), with every constituent constant (`gbar=kappa*h`, `mbar=kappa*h/Thorn_R`, `C0`, `zeta`, `K_L`, `K_R`, `c2`, `tbar`) taken verbatim from `stage_A_proof.md` §5 and the binding term reported. This is a DIAGNOSTIC, never a refusal gate: the constants are deliberately CRUDE (statement.md Remark 7) — `wbar0` certifies "the tail is provably valid beyond here" with explicit constants, NOT where extrapolation first becomes accurate (empirically much earlier, near `m ~ hNrm`; the operative ex-post quality rule stays `aXtraMax_from_tail_tol`). Fails closed to `nan` + a diagnosis under GIC/FHWC/RIC violations (never raises). THEOREM-REF-pinned to (5.0a)/(5.0); hand-computed unit test. [#1782](https://github.com/econ-ark/HARK/pull/1782) | ||
| - Adds `pf_decay.ergodic_grid_diagnostics` / `ergodic_grid_diagnostics_from_agent` (the ex-ante patience screen) and `pf_decay.ergodic_grid_report` (the postmortem coverage certificate) for grid design, with `ErgodicGridDiagnostics` / `ErgodicGridReport` / `ErgodicCoverageWarning`. The *shape* of the ergodic distribution of the market-resources ratio `m` is pinned ex ante by patience primitives (a Kesten random-growth process), so the tail exponent — survivor / agent-counting / Harmenberg, computed on the model's OWN discretized `psi` atoms via the mortality-EFFECTIVE patience ladder (`beta_eff = DiscFac*LivPrb`; the raw-`beta` `conditions` machinery can disagree — College-TOP has raw GIC-Nrm failing while effective holds) — is available WITHOUT a solve; only the distribution's location needs one. The numeric core is NOT reimplemented in HARK: it is soft-IMPORTED (lazily, at call time, never at HARK import time; `API_LEVEL` drift-guarded) from BufferStockTheory-Latest's `ergodic_coverage_lib` (the single source of truth), so the diagnostics — and their tests/notebook cells — are dormant/skipped wherever that checkout is absent (public CI unaffected). The report is HARK-native: a fixed-seed `agent.simulate()` on a `deepcopy` (it never mutates, re-grids, or re-solves the user's agent), the imported Hill estimator, and a coverage-vs-accuracy pairing with `rel_gap_at` at the top knot. Both are PASSIVE diagnostics — advisory `UserWarning` only, never an action; defaults byte-zero. Theory of record: BST-Latest `ergodic_coverage.md` (cited, not restated). [#1782](https://github.com/econ-ark/HARK/pull/1782) | ||
| - Documents and regression-tests the constraint-end tail's binding-constraint gate: the `decay_extrap_form_lower='kappabar'` bottom tail attaches whenever the natural constraint is the binding one — `BoroCnstArt is None` OR `BoroCnstNat >= BoroCnstArt` (evaluated per backward step, so a lifecycle agent's gate can flip across ages) — and refuses only when an artificial constraint STRICTLY binds. New tests cover an artificial-but-slack constraint (tail built, nested-grid fidelity vs the rails secant) and a lifecycle whose per-age natural constraint crosses a fixed artificial one (per-period wrap types flip). The gate wording ("natural-constraint branch only") is corrected accordingly. [#1782](https://github.com/econ-ark/HARK/pull/1782) | ||
| ||||||| a25d3ae0 |
| if _FENCE_RE.match(ln): | ||
| in_fence = not in_fence | ||
| continue | ||
| if in_fence: |
| healthy = level_diff > tol and slope_top > MPCmin | ||
| # the fitted exponent LinearInterp would infer at this knot (diagnostic; | ||
| # may be <= 0 outside the healthy branch) | ||
| Q_fit = ( |
There was a problem hiding this comment.
Following up on my review with a design observation rather than a bug: this guarded_fit branch - fitting Q from the boundary geometry, Q_fit = (slope_top - MPCmin)(m_top + hNrm)/level_diff - is a measured local exponent, and it is the arm of this architecture I would trust most. It requires no theory input, no regime classification, and it automatically tracks the pre-asymptotic transit that a fixed theory Q cannot (on the calibrations I have measured, the true local exponent reaches its asymptote only at many multiples of human wealth, non-monotonically, with an overshoot). The theory value of q is exactly right as a diagnostic target - the thing the fitted exponent should migrate toward as grids deepen, which powerlaw_tail_diagnostic already operationalizes - but as the imposed extrapolation exponent it is only correct where the fit already agrees with it. Concretely: consider making guarded_fit the default arm and 'theory' the opt-in, rather than the reverse. The fallback here is better than the headline mode, and the module's own diagnostic machinery is the right home for q*.
What this PR does
Buffer-stock consumption functions approach their perfect-foresight asymptote
c → MPCmin·(m + hNrm)as a power law inx = m + h, not exponentially: the gapg(x) = MPCmin·(m+h) − c(m)decays likex^(−min(1, q*)), whereq*solvesE[ψ^(1+q)] = (R/Γ)·Þ_Γ^q. (A prudent consumer always collects at least today'sArrow–Pratt premium, so the gap can never decay faster than
1/x— exponential tailsare asymptotically impossible.) HARK's existing
decay_extrapuses an exponentialform; above the top gridpoint this systematically mis-extrapolates, and in the 2D
aggregate-shock solvers the per-state cFunc slices had no decay limits at all
(naive-linear extrapolation). This PR fixes the tail law end to end, all strictly
opt-in:
LinearInterp(decay_extrap_form='powerlaw')— the power-law tail, level- andslope-matched at the top knot exactly like the exponential form; default remains
'exp'(byte-for-byte unchanged).decay_extrap_Q— attach the tail with an EXPLICIT exponent(theory value
min(1, q*)) instead of the fitted 2-knot estimator; also rescuesthe
B ≤ 0fallback cases where fitted decay had to be disabled.ConsAggShockModel—pf_mpc_min,pf_human_wealth_markov, andmake_cFunc_sliceattach per-Markov-state decaytoward
MPCmin·(m + hNrm[i]), with a Carroll–Kimball concavity guard. With thenew solver arguments at their
Nonedefaults, behavior is byte-for-byte unchanged.HARK.ConsumptionSaving.pf_decay— the theory module (numpy-only, no solve):q*, realized exponentmin(1, q*),h = 1/(R/Γ − 1)(excludes current income —the module documents and avoids HARK's two-convention
hNrmtrap), closed-formtail amplitude
B_ψ(atq* > 1), near-resonance multiplier, the dual (Kesten)wealth-tail root
ζ*, GIC/RIC/FHWC flags; refuses cleanly (nan/None + filterablewarnings) outside the theorem's hypotheses.
powerlaw_tail_diagnostic— a cheap post-solve wrong-exponent detector(CONFIRMED / PRE_ASYMPTOTIC / INCONSISTENT / UNMEASURABLE) for grid-quality audits.
rel_gap_at+aXtraMax_from_tail_tol:two routes to the
mwhere the relative gap reaches a chosen tolerance — atq* > 1a closed-form GUARANTEEx_top = sqrt(B_ψ/(MPCmin·tol))(the compensatedgap climbs monotonically to
B_ψ, soB_ψ/xbounds the gap from above); atq* ≤ 1a measured-reference inversion ofgap/c ∝ x^(−(1+q)). Beyond that top,ANY level-matched monotone below-line tail (in particular the power-law form) is
within
gap(m_top)of the truth pointwise (Carroll–Kimball concavity), so the gridmay certifiably stop there; the ex-post check is one
rel_gap_atcall. Measured onthe anchor calibrations: HS 0.40× tol, College-top 0.98×, GIC-cap (closed form)
0.69×.
tools/update_theorem_refs.py— the code cites its theorem program via pinnedTHEOREM-REF[repo @ sha :: file :: section :: label]comment tags; the toolverifies/re-pins them (on public CI without the private theorem repo it degrades
gracefully to syntax checking).
DecayTailInterp— the decay tail as a COMPOSABLE wrapper over ANY 1Dinterpolant (the decoupling of tail law from in-grid representation): at/below a
handoff
x_cutit delegates to the wrapped body; above, it applies the same tailfamily, guards, and stable evaluation as
LinearInterp's built-in machinery,sourced from the body's level/slope at the cut — byte-identical to the baked-in
tails over a bare linear body (unit-gated), and it composes with
CubicInterp(which has only the legacy exponential decay), econforge interpolants, or any
bare callable (explicit-exponent mode needs no derivative). Supports off-knot
cuts (certified truncation of a solved cFunc). LEVEL CONTINUITY AT THE CUT IS
AN INVARIANT (owner ruling 2026-07-11): every attachable tail is level-matched
— there is deliberately no amplitude-override hook, and
make_cFunc_slice'sformer jump-permitting
('amplitude', B)mode was removed in the same ruling.Its
'exp'form is deprecated at birth (warns) whileLinearInterp's legacydefault stays untouched. Explicit-exponent tails default to the C1 TWO-TERM
attachment (
decay_extrap_terms=2; one-term opt-in) — level- ANDslope-matched with the theory exponent leading — SPECIFICALLY to guard
against Jacobian problems in SSJ-type (sequence-space Jacobian) approaches,
where policy derivatives are primitive inputs and the one-term kink makes
them discontinuous at the attachment point.
What this PR deliberately does NOT do
An importance-weighted gridpoint-PLACEMENT scheme built on the same theory was
prototyped under a pre-registered validation program with hard accept/kill gates. It
failed its gates (its near-constraint Euler accuracy lost 2× to
make_grid_exp_mult's kink-stacking on one anchor calibration, despite winning thedistribution/moment metrics broadly) and was dropped per the pre-registered kill
provision. Only the components that passed everywhere ship here (the tail law and the
extent criterion). This is why there is no
grid_placementmodule in this PR.Guarantees
(the AggShock machinery's defaults keep the prior bare-
LinearInterpbehavior;adversarially re-verified: additive-only commits, no runtime callers of the new
functions).
test_interpolation, test_theorem_refs). THEOREM-REF tags 44/44 resolve at their pins.
pf_decayis numpy-only).Commits
208f78f1opt-in PF-asymptote decay machinery (AggShock)fed6f368power-law decay form onLinearInterp6c4af317power-law as the AggShock machinery's decay default + tests4909c5c8pf_decaytheory module + testsd8afd272explicit-exponentdecay_extrap_Q30606d81theory-informed tail policy threading (decay_theory/decay_Q)b04e70d6powerlaw_tail_diagnosticd8c0d277THEOREM-REF updater tool + tag sete81ff286root-finder hardening near the r = g knife-edge64e83bfacertified grid-extent criterion5a167515extent-criterion hardening per adversarial review (closed-form q*>1route; empty-input and bad-safety fail-closed fixes; corrected q_eff rationale)
4e46ee73qstar_probe(the operator eigen-probe: numerical q* from the model'sown one-period step — for model variants with no closed-form eigen-equation;
validated to 5.6e-6..5.0e-5 vs the analytic roots) +
mNrm_stable_points(classical targets + the mortality-adjusted R→LivPrb·R twins that exist in the
pure-GIC case, with end-of-period-asset images)
45ff3ab8aXtraMax_from_wealth_mass+dual_root(..., LivPrb): the grid top asa measured stationary wealth quantile with the mortality-augmented dual root ζ_L as
existence test and patience dial; certified protocol reproduces dense-truth
quantiles to 0.1–3.4% (theorem-repo battery 11/11)
e126dd65DecayTailInterp: the decay tail as a composable wrapper over any 1Dinterpolant (the owner's decoupling principle) — byte-identical to the baked-in
LinearInterp tails over a linear body, composes with cubic/econforge/bare-callable
bodies, off-knot certified truncation;
'exp'deprecated at birth in the newclass; adversarially refuted pre-commit (one real ZeroDivisionError boundary bug
found and fixed); theorem-repo battery
verify_decaytail_checks.py14/148e93c68elevel continuity at the top knot made an INVARIANT of the decaymachinery (owner ruling 2026-07-11):
DecayTailInterp's amplitude override andmake_cFunc_slice's jump-permitting('amplitude', B)mode removed (tuple formraises, pointing at the level-matched
decay_Q=1.0;B_ψkeeps its diagnosticamplitude-ratio log); life-cycle and infinite-horizon tails use the SAME
level-matched form family (exponent min(1, q*) vs exactly 1 per age)
d6af964cthe C1 TWO-TERM attachment as the explicit-exponent DEFAULT(
decay_extrap_terms=2|1, threaded asdecay_terms; owner ruling 2026-07-11,motivation = SSJ-type Jacobian robustness):
gap = A·z^(−Q) + A2·z^(−(Q+1))with F11 closed forms, level- AND slope-matched (C1 residual ~2e-16 at the
theorem anchors), exact collapse stored as one-term (byte-identical), warned
one-term fallback at non-finite or theory-infeasible fitted rates
(adversarially hardened; second refuter pass); theorem-repo battery 24/24,
two-term accuracy 2.3e-4 at the HS handoff
b6285f0adecay_extrap_form='moderation_tail'onDecayTailInterp(+ requiredx_min): guard-free C1 tail in Method-of-Moderation coordinates (tail-only; NOTthe full Method of Moderation) — level- and slope-matched at ANY cut including the
Q_fit >= Q+1region where the two-term form must fall back; bounds built in;same asymptotic power law
gap ~ (x - x_min)^-Q; exact collapse to the pinnedchi-line at
chip_cut == Q; moderation-premise violations raise. Two-termguard-trip warnings (LinearInterp + DecayTailInterp) now name cause and remedies,
and print the exact steepness decomposition
Q_fit = s_mu*(1 + hEx/mEx)with theguard-safe boundary
mEx > Q*hExwhenx_minis supplied. 11 new tests(suite 166/166); cross-verified at machine precision (<= 4.4e-16) against the
downstream Method-of-Moderation implementation of the same form
a5c70684CHANGELOG: the moderation_tail family + enriched guard diagnosisRelated planned work (2026-07-12; neither item changes this PR's current content)
aXtraMaxdefault — a separate, independent PR (norequired merge order in either direction). Buffer-stock asset-grid tops
will default to human-wealth scale (
max(20, hNrm), with a documentedfallback of 1000 when FHWC fails and
hNrmis infinite; explicit uservalues always win). Motivation: the local decay exponent measured at any
grid top equals the gap's intrinsic log-slope times a coordinate
amplification factor
1 + hEx/mEx, so conventional tops (aXtraMax = 20)sit deep in the pre-asymptotic regime and slopes fitted there are
amplified artifacts — including the fitted rates that trip this PR's
two-term guard. Complementary to this PR's
aXtraMax_from_tail_tol: thattool certifies where a grid may STOP (tolerance-based); the default sets
where the top must REACH for top-knot slopes and fitted attachments to be
meaningful. Shared surface is limited to the CHANGELOG; the integration
test (guard no longer trips at the new default) is import-guarded and
activates only when both PRs are merged, in either order.
DecayTailInterp(
decay_extrap_form='moderation_tail'), riding this branch. Thetwo-term C1 attachment is guarded by
Q_fit < Q+1, and at human-wealth-dominated grid tops the guard trips generically, falling back to the
C0 one-term tail (an MPC kink of order
(Q_fit − Q)·G/x_cutat theattachment point). The planned family attaches in Method-of-Moderation
coordinates (tail-only — NOT the full Method of Moderation solution
representation): level- and slope-matched at ANY cut with no guard,
bounded between the pessimist and optimist lines by construction, and
asymptotically the same power law
x^(−Q). LANDED on this branch asb6285f0a+a5c70684(see Commits); if it is ever split into its ownPR instead, that PR requires this one and must merge strictly after it.
🤖 Generated with Claude Code