Skip to content

Power-law PF-decay tails for buffer-stock consumption functions: correct tail law, theory module, diagnostics, and a certified grid-extent criterion - #1782

Draft
llorracc wants to merge 36 commits into
mainfrom
fix-aggshock-pf-decay-extrap
Draft

Power-law PF-decay tails for buffer-stock consumption functions: correct tail law, theory module, diagnostics, and a certified grid-extent criterion#1782
llorracc wants to merge 36 commits into
mainfrom
fix-aggshock-pf-decay-extrap

Conversation

@llorracc

@llorracc llorracc commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Buffer-stock consumption functions approach their perfect-foresight asymptote
c → MPCmin·(m + hNrm) as a power law in x = m + h, not exponentially: the gap
g(x) = MPCmin·(m+h) − c(m) decays like x^(−min(1, q*)), where q* solves
E[ψ^(1+q)] = (R/Γ)·Þ_Γ^q. (A prudent consumer always collects at least today's
Arrow–Pratt premium, so the gap can never decay faster than 1/x — exponential tails
are asymptotically impossible.) HARK's existing decay_extrap uses an exponential
form; 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:

  1. LinearInterp(decay_extrap_form='powerlaw') — the power-law tail, level- and
    slope-matched at the top knot exactly like the exponential form; default remains
    'exp' (byte-for-byte unchanged).
  2. Keyword-only decay_extrap_Q — attach the tail with an EXPLICIT exponent
    (theory value min(1, q*)) instead of the fitted 2-knot estimator; also rescues
    the B ≤ 0 fallback cases where fitted decay had to be disabled.
  3. Opt-in PF-asymptote machinery for ConsAggShockModelpf_mpc_min,
    pf_human_wealth_markov, and make_cFunc_slice attach per-Markov-state decay
    toward MPCmin·(m + hNrm[i]), with a Carroll–Kimball concavity guard. With the
    new solver arguments at their None defaults, behavior is byte-for-byte unchanged.
  4. HARK.ConsumptionSaving.pf_decay — the theory module (numpy-only, no solve):
    q*, realized exponent min(1, q*), h = 1/(R/Γ − 1) (excludes current income —
    the module documents and avoids HARK's two-convention hNrm trap), closed-form
    tail amplitude B_ψ (at q* > 1), near-resonance multiplier, the dual (Kesten)
    wealth-tail root ζ*, GIC/RIC/FHWC flags; refuses cleanly (nan/None + filterable
    warnings) outside the theorem's hypotheses.
  5. powerlaw_tail_diagnostic — a cheap post-solve wrong-exponent detector
    (CONFIRMED / PRE_ASYMPTOTIC / INCONSISTENT / UNMEASURABLE) for grid-quality audits.
  6. A certified grid-extent criterionrel_gap_at + aXtraMax_from_tail_tol:
    two routes to the m where the relative gap reaches a chosen tolerance — at
    q* > 1 a closed-form GUARANTEE x_top = sqrt(B_ψ/(MPCmin·tol)) (the compensated
    gap climbs monotonically to B_ψ, so B_ψ/x bounds the gap from above); at
    q* ≤ 1 a measured-reference inversion of gap/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 grid
    may certifiably stop there; the ex-post check is one rel_gap_at call. Measured on
    the anchor calibrations: HS 0.40× tol, College-top 0.98×, GIC-cap (closed form)
    0.69×.
  7. tools/update_theorem_refs.py — the code cites its theorem program via pinned
    THEOREM-REF[repo @ sha :: file :: section :: label] comment tags; the tool
    verifies/re-pins them (on public CI without the private theorem repo it degrades
    gracefully to syntax checking).
  8. DecayTailInterp — the decay tail as a COMPOSABLE wrapper over ANY 1D
    interpolant (the decoupling of tail law from in-grid representation): at/below a
    handoff x_cut it delegates to the wrapped body; above, it applies the same tail
    family, 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's
    former jump-permitting ('amplitude', B) mode was removed in the same ruling.
    Its 'exp' form is deprecated at birth (warns) while LinearInterp's legacy
    default stays untouched. Explicit-exponent tails default to the C1 TWO-TERM
    attachment (decay_extrap_terms=2; one-term opt-in) — level- AND
    slope-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 the
distribution/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_placement module in this PR.

Guarantees

  • Everything is opt-in; with no new arguments used, HARK is byte-for-byte unchanged
    (the AggShock machinery's defaults keep the prior bare-LinearInterp behavior;
    adversarially re-verified: additive-only commits, no runtime callers of the new
    functions).
  • Test suites: 249 passed (test_pf_decay 66, test_AggShock_pf_decay,
    test_interpolation, test_theorem_refs). THEOREM-REF tags 44/44 resolve at their pins.
  • No new dependencies (pf_decay is numpy-only).

Commits

  • 208f78f1 opt-in PF-asymptote decay machinery (AggShock)
  • fed6f368 power-law decay form on LinearInterp
  • 6c4af317 power-law as the AggShock machinery's decay default + tests
  • 4909c5c8 pf_decay theory module + tests
  • d8afd272 explicit-exponent decay_extrap_Q
  • 30606d81 theory-informed tail policy threading (decay_theory/decay_Q)
  • b04e70d6 powerlaw_tail_diagnostic
  • d8c0d277 THEOREM-REF updater tool + tag set
  • e81ff286 root-finder hardening near the r = g knife-edge
  • 64e83bfa certified grid-extent criterion
  • 5a167515 extent-criterion hardening per adversarial review (closed-form q*>1
    route; empty-input and bad-safety fail-closed fixes; corrected q_eff rationale)
  • 4e46ee73 qstar_probe (the operator eigen-probe: numerical q* from the model's
    own 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)
  • 45ff3ab8 aXtraMax_from_wealth_mass + dual_root(..., LivPrb): the grid top as
    a 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)
  • e126dd65 DecayTailInterp: the decay tail as a composable wrapper over any 1D
    interpolant (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 new
    class; adversarially refuted pre-commit (one real ZeroDivisionError boundary bug
    found and fixed); theorem-repo battery verify_decaytail_checks.py 14/14
  • 8e93c68e level continuity at the top knot made an INVARIANT of the decay
    machinery (owner ruling 2026-07-11): DecayTailInterp's amplitude override and
    make_cFunc_slice's jump-permitting ('amplitude', B) mode removed (tuple form
    raises, pointing at the level-matched decay_Q=1.0; B_ψ keeps its diagnostic
    amplitude-ratio log); life-cycle and infinite-horizon tails use the SAME
    level-matched form family (exponent min(1, q*) vs exactly 1 per age)
  • d6af964c the C1 TWO-TERM attachment as the explicit-exponent DEFAULT
    (decay_extrap_terms=2|1, threaded as decay_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
  • b6285f0a decay_extrap_form='moderation_tail' on DecayTailInterp (+ required
    x_min): guard-free C1 tail in Method-of-Moderation coordinates (tail-only; NOT
    the full Method of Moderation) — level- and slope-matched at ANY cut including the
    Q_fit >= Q+1 region where the two-term form must fall back; bounds built in;
    same asymptotic power law gap ~ (x - x_min)^-Q; exact collapse to the pinned
    chi-line at chip_cut == Q; moderation-premise violations raise. Two-term
    guard-trip warnings (LinearInterp + DecayTailInterp) now name cause and remedies,
    and print the exact steepness decomposition Q_fit = s_mu*(1 + hEx/mEx) with the
    guard-safe boundary mEx > Q*hEx when x_min is 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
  • a5c70684 CHANGELOG: the moderation_tail family + enriched guard diagnosis

Related planned work (2026-07-12; neither item changes this PR's current content)

  • Primitives-based aXtraMax default — a separate, independent PR (no
    required merge order in either direction).
    Buffer-stock asset-grid tops
    will default to human-wealth scale (max(20, hNrm), with a documented
    fallback of 1000 when FHWC fails and hNrm is infinite; explicit user
    values 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: that
    tool 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.
  • A third tail family for DecayTailInterp
    (decay_extrap_form='moderation_tail'), riding this branch.
    The
    two-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_cut at the
    attachment 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 as
    b6285f0a + a5c70684 (see Commits); if it is ever split into its own
    PR instead, that PR requires this one and must merge strictly after it.

🤖 Generated with Claude Code

llorracc and others added 20 commits July 5, 2026 02:47
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>
@llorracc llorracc closed this Jul 13, 2026
@llorracc
llorracc deleted the fix-aggshock-pf-decay-extrap branch July 13, 2026 20:22
@llorracc
llorracc restored the fix-aggshock-pf-decay-extrap branch July 13, 2026 20:27
@llorracc llorracc reopened this Jul 13, 2026
@llorracc
llorracc marked this pull request as draft July 14, 2026 14:27
llorracc and others added 5 commits July 14, 2026 22:24
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>
llorracc and others added 11 commits July 14, 2026 23:30
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 alanlujan91 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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).
  2. time_inv_ gains ten names unconditionally, so out-of-tree AggShockConsumerType subclasses that don't route through your __init__ now hit KeyError on solve (inline).
  3. The AggShock theory branch uses decay_theory.q without checking .valid, while the IndShock path raises in the same situation (inline).
  4. The tail diagnostic's one-sided INCONSISTENT band is wrong when q* < 1 (inline at pf_decay.py).
  5. The kappabar bottom tail can silently fail to attach all the way to convergence, with no signal (inline at ConsIndShockModel.py).
  6. The grid-sizing functions - your own "operative quality rule" - return bare nan on five distinct failure modes, and the tail_tol clamp 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1347 to +1348
x_top = np.sqrt(B_psi / (MPCmin * tail_tol))
return float(x_top - hNrm)
Comment on lines +606 to +614
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")
Comment on lines +2418 to +2421
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])
Comment thread docs/CHANGELOG.md
- 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
Comment on lines +370 to +373
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 = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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*.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants