From f9f5f1b4cfa5d0c5ba2ecb13b0279ac0444c380c Mon Sep 17 00:00:00 2001 From: marcelolafleur Date: Tue, 21 Jul 2026 12:04:31 -0400 Subject: [PATCH 1/3] Stop the TPI outer loop early when it has stalled, with a diagnosis Adds solvers.diagnose_stall, a window check on the existing TPIdist_vec history: when the best distance over the last TPI_stall_window iterations (default 50; 0 disables) has not improved on the best from before the window, the loop is diagnosed as stalled. The message distinguishes a cycling outer loop (suggest a lower nu or TPI_outer_method='anderson') from a diverging economy (usually an inconsistent fiscal block, which solver settings cannot fix). The default TPI_stall_action='warn' logs the diagnosis once and leaves model solutions unchanged; 'stop' also ends the loop early so the run fails through the existing ENFORCE_SOLUTION_CHECKS path instead of spending the rest of maxiter. Works identically for the picard and anderson update rules. Verified on the motivating case from issue #1177 (an OG-PHL multi-industry reform at nu=0.4): the stall is diagnosed at iteration 100 and the run stops, where the same configuration previously churned past iteration 203 of 250; the converging baseline transition (~76 iterations) triggers nothing. --- CHANGELOG.md | 17 +++++++++++++++ ogcore/TPI.py | 34 +++++++++++++++++++++++++++++ ogcore/default_parameters.json | 40 ++++++++++++++++++++++++++++++++++ ogcore/solvers.py | 39 +++++++++++++++++++++++++++++++++ tests/test_TPI.py | 33 ++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e60d69cc5..85ce1456e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Stall detection for the TPI outer loop (Issue #1177): when the best + distance has not improved over the last `TPI_stall_window` iterations + (default 50; 0 disables), `run_TPI` logs a diagnosis distinguishing a + cycling outer loop (suggesting a lower `nu` or + `TPI_outer_method="anderson"`) from a diverging economy (usually an + inconsistent fiscal block, which solver settings cannot fix). The + default `TPI_stall_action="warn"` only logs, leaving model solutions + unchanged; `"stop"` also ends the loop early, so a hopeless run fails + through the existing non-convergence checks instead of spending the + rest of `maxiter`. The window check lives in + `ogcore.solvers.diagnose_stall` and works for both the picard and + anderson update rules. + ## [0.17.0] - 2026-07-16 12:00:00 ### Bug Fixes diff --git a/ogcore/TPI.py b/ogcore/TPI.py index faec9be5d..fe7a21bab 100644 --- a/ogcore/TPI.py +++ b/ogcore/TPI.py @@ -931,6 +931,7 @@ def run_TPI(p, client=None): TPIdist = 10 euler_errors = np.zeros((p.T, 2 * p.S, p.J)) TPIdist_vec = np.zeros(p.maxiter) + stall_reported = False # Pluggable outer-loop update rule. Default "picard" -> None -> the native # damped functional-iteration path below (unchanged, so golden outputs # are preserved); "anderson" accelerates using the residual history. See @@ -1525,6 +1526,39 @@ def run_TPI(p, client=None): TPIiter += 1 logger.info(f"Iteration: {TPIiter}") logger.info(f"Distance: {TPIdist}") + # Stall detection: when the best distance has stopped improving + # over a window of iterations, further iterations repeat the same + # pattern and cannot reach the tolerance -- diagnose the cause and, + # if TPI_stall_action="stop", end the loop early (the solution + # checks after the loop then fail the run as any non-convergence). + # In the default warn mode the diagnosis is logged once per stall. + stall = solvers.diagnose_stall( + TPIdist_vec, TPIiter, p.TPI_stall_window + ) + if stall is None: + stall_reported = False + else: + if not stall_reported: + stall_reported = True + if stall == "diverging": + logger.error( + "TPI stalled and diverging: the distance grew for " + f"{p.TPI_stall_window} straight iterations. This " + "usually signals an inconsistent fiscal block " + "(spending, revenue, and debt_ratio_ss), not a " + "solver problem." + ) + else: + logger.error( + "TPI stalled: the best distance has not improved " + f"over the last {p.TPI_stall_window} iterations " + f"(current {TPIdist:.2e}, tolerance " + f"{p.mindist_TPI}). The outer loop is cycling; try " + "a lower nu, or TPI_outer_method='anderson' if not " + "already enabled." + ) + if p.TPI_stall_action == "stop": + break # Compute effective and marginal tax rates for all agents num_params = len(p.mtrx_params[0][0]) diff --git a/ogcore/default_parameters.json b/ogcore/default_parameters.json index 9055bfa96..fa4865138 100644 --- a/ogcore/default_parameters.json +++ b/ogcore/default_parameters.json @@ -4668,6 +4668,46 @@ } } }, + "TPI_stall_window": { + "title": "Iteration window for TPI stall detection", + "description": "Number of trailing TPI outer-loop iterations over which the best distance must improve on the best from before the window. When it does not, the loop has stalled (cycling or diverging) and a diagnosis is logged; see TPI_stall_action for whether the loop also stops. A value of 0 disables stall detection.", + "short_description": "TPI stall-detection window", + "section_1": "Model Solution Parameters", + "notes": "", + "type": "int", + "value": [ + { + "value": 50 + } + ], + "validators": { + "range": { + "min": 0, + "max": 500 + } + } + }, + "TPI_stall_action": { + "title": "Action when TPI stall detection fires", + "description": "What to do when stall detection (TPI_stall_window) diagnoses a stalled TPI outer loop. 'warn' (default) logs the diagnosis once and lets the loop continue, leaving model solutions unchanged; 'stop' also ends the loop early, so the run fails through the usual non-convergence checks instead of spending the rest of maxiter.", + "short_description": "TPI stall action", + "section_1": "Model Solution Parameters", + "notes": "", + "type": "str", + "value": [ + { + "value": "warn" + } + ], + "validators": { + "choice": { + "choices": [ + "warn", + "stop" + ] + } + } + }, "SS_root_method": { "title": "Root finding algorithm for outer loop of the SS solution", "description": "Root finding algorithm for outer loop of the SS solution.", diff --git a/ogcore/solvers.py b/ogcore/solvers.py index e44907ed2..ee6b7600c 100644 --- a/ogcore/solvers.py +++ b/ogcore/solvers.py @@ -191,6 +191,45 @@ def make_outer_updater(method, p): raise ValueError(f"unknown TPI_outer_method: {method!r}") +def diagnose_stall(dist_vec, iteration, window, tol=0.05): + """ + Check the TPI outer loop's distance history for a stall. + + The loop has stalled when the best distance over the most recent + ``window`` iterations is no better (by a relative margin ``tol``) + than the best from before that window: the remaining iterations + repeat the same pattern and cannot reach the tolerance. A distance + that grew every iteration of the window points to a diverging + economy (typically an inconsistent fiscal block); a bounded bounce + points to the outer loop cycling around the solution (typically + ``nu`` too large for the problem). + + Args: + dist_vec (Numpy array): per-iteration outer-loop distances, + filled for the first ``iteration`` entries + iteration (int): number of completed outer-loop iterations + window (int): number of trailing iterations that must improve + on the earlier best; a non-positive value disables the + check + tol (float): minimum relative improvement of the recent best + over the earlier best that counts as progress + + Returns: + diagnosis (str or None): None while the loop is progressing + (or the check is disabled), otherwise "diverging" or + "oscillating" + """ + if window <= 0 or iteration < 2 * window: + return None + recent = dist_vec[iteration - window : iteration] + earlier_best = dist_vec[: iteration - window].min() + if recent.min() < (1.0 - tol) * earlier_best: + return None + if np.all(np.diff(recent) > 0): + return "diverging" + return "oscillating" + + def _selftest(): """ Validate the accelerator math on a linear contraction fixed point, diff --git a/tests/test_TPI.py b/tests/test_TPI.py index da92f8cc3..5c93e58b3 100644 --- a/tests/test_TPI.py +++ b/tests/test_TPI.py @@ -335,6 +335,39 @@ def test_anderson_scaling_and_reset(): assert u._F == [] and u._X == [] +def test_stall_defaults_are_warn_only(): + # Stall detection defaults: a 50-iteration window and warn-only + # action, so model solutions are unchanged. + p = Specifications() + assert p.TPI_stall_window == 50 + assert p.TPI_stall_action == "warn" + + +def test_diagnose_stall_progressing_and_disabled(): + # A steadily improving distance history is never a stall, and a + # non-positive window (or too-short history) disables the check. + improving = 10.0 * 0.9 ** np.arange(100) + assert solvers.diagnose_stall(improving, 100, 20, 0.05) is None + stuck = np.full(100, 5.0) + assert solvers.diagnose_stall(stuck, 100, 0, 0.05) is None + assert solvers.diagnose_stall(stuck, 30, 20, 0.05) is None + + +def test_diagnose_stall_oscillating(): + # A distance that bounces in a band without improving on the earlier + # best is diagnosed as the outer loop cycling. + rng = np.arange(100) + bouncing = 0.3 + 0.25 * (-1.0) ** rng + assert solvers.diagnose_stall(bouncing, 100, 20, 0.05) == "oscillating" + + +def test_diagnose_stall_diverging(): + # A distance that grew every iteration of the window is diagnosed as + # a diverging economy rather than a cycling solver. + growing = 0.1 * 1.05 ** np.arange(100) + assert solvers.diagnose_stall(growing, 100, 20, 0.05) == "diverging" + + file_in1 = os.path.join( CUR_PATH, "test_io_data", "twist_doughnut_inputs_2.pkl" ) From a5669ed442d82bc443eee365c3fd84ec93694933 Mon Sep 17 00:00:00 2001 From: marcelolafleur Date: Tue, 21 Jul 2026 12:38:19 -0400 Subject: [PATCH 2/3] Diagnose divergence by deterioration of the best distance, not monotone growth Live testing showed the monotone test never fires on real models: a fiscal runaway either produces NaN within an iteration (already caught by the existing loop guards and RC check) or manifests as a widening bounce, which strict monotonicity misreads as cycling. The diverging diagnosis now fires when the recent window's best distance is far above the earlier best (default 2x) -- deterioration, not monotonicity, is the signature that the path is drifting away. A converging run with a temporary +8pp-of-GDP spending shock stays silent (no false positive), and a new unit test covers the widening-bounce case the old rule got wrong. --- ogcore/TPI.py | 10 +++++----- ogcore/solvers.py | 18 +++++++++++------- tests/test_TPI.py | 8 ++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/ogcore/TPI.py b/ogcore/TPI.py index fe7a21bab..2a3c9e26c 100644 --- a/ogcore/TPI.py +++ b/ogcore/TPI.py @@ -1542,11 +1542,11 @@ def run_TPI(p, client=None): stall_reported = True if stall == "diverging": logger.error( - "TPI stalled and diverging: the distance grew for " - f"{p.TPI_stall_window} straight iterations. This " - "usually signals an inconsistent fiscal block " - "(spending, revenue, and debt_ratio_ss), not a " - "solver problem." + "TPI stalled and diverging: the best distance over " + f"the last {p.TPI_stall_window} iterations is far " + "above the earlier best. This usually signals an " + "inconsistent fiscal block (spending, revenue, and " + "debt_ratio_ss), not a solver problem." ) else: logger.error( diff --git a/ogcore/solvers.py b/ogcore/solvers.py index ee6b7600c..98cb9120c 100644 --- a/ogcore/solvers.py +++ b/ogcore/solvers.py @@ -191,18 +191,19 @@ def make_outer_updater(method, p): raise ValueError(f"unknown TPI_outer_method: {method!r}") -def diagnose_stall(dist_vec, iteration, window, tol=0.05): +def diagnose_stall(dist_vec, iteration, window, tol=0.05, deterioration=2.0): """ Check the TPI outer loop's distance history for a stall. The loop has stalled when the best distance over the most recent ``window`` iterations is no better (by a relative margin ``tol``) than the best from before that window: the remaining iterations - repeat the same pattern and cannot reach the tolerance. A distance - that grew every iteration of the window points to a diverging - economy (typically an inconsistent fiscal block); a bounded bounce - points to the outer loop cycling around the solution (typically - ``nu`` too large for the problem). + repeat the same pattern and cannot reach the tolerance. A recent + best that is far worse than the earlier best (``deterioration``) + means the path is drifting away -- a diverging economy, typically + an inconsistent fiscal block; a bounce around the earlier best + means the outer loop is cycling around the solution, typically + ``nu`` too large for the problem. Args: dist_vec (Numpy array): per-iteration outer-loop distances, @@ -213,6 +214,9 @@ def diagnose_stall(dist_vec, iteration, window, tol=0.05): check tol (float): minimum relative improvement of the recent best over the earlier best that counts as progress + deterioration (float): factor by which the recent best must + exceed the earlier best to be read as divergence rather + than cycling Returns: diagnosis (str or None): None while the loop is progressing @@ -225,7 +229,7 @@ def diagnose_stall(dist_vec, iteration, window, tol=0.05): earlier_best = dist_vec[: iteration - window].min() if recent.min() < (1.0 - tol) * earlier_best: return None - if np.all(np.diff(recent) > 0): + if recent.min() >= deterioration * earlier_best: return "diverging" return "oscillating" diff --git a/tests/test_TPI.py b/tests/test_TPI.py index 5c93e58b3..62e41d942 100644 --- a/tests/test_TPI.py +++ b/tests/test_TPI.py @@ -362,10 +362,14 @@ def test_diagnose_stall_oscillating(): def test_diagnose_stall_diverging(): - # A distance that grew every iteration of the window is diagnosed as - # a diverging economy rather than a cycling solver. + # A recent best far above the earlier best is diagnosed as a + # diverging economy rather than a cycling solver -- whether the + # growth is smooth or a widening bounce. growing = 0.1 * 1.05 ** np.arange(100) assert solvers.diagnose_stall(growing, 100, 20, 0.05) == "diverging" + rng = np.arange(100) + growing_bounce = 0.05 * 1.1**rng * (1.0 + 0.3 * (-1.0) ** rng) + assert solvers.diagnose_stall(growing_bounce, 100, 20, 0.05) == "diverging" file_in1 = os.path.join( From 6e93c18ab74de64260c44affef337c7f2f69289d Mon Sep 17 00:00:00 2001 From: marcelolafleur Date: Tue, 21 Jul 2026 14:37:22 -0400 Subject: [PATCH 3/3] Carry the stall diagnosis into the terminal error; re-log only when it changes The final RuntimeError now appends the stall diagnosis and its cure (cycling: lower nu / anderson; diverging: check the fiscal block), so the guidance reaches users who only see the traceback. The warn-mode log fires once per diagnosis and again only if it changes (e.g. escalates from cycling to diverging), replacing the boolean once-per-stall guard with a smaller comparison against the last reported label. --- CHANGELOG.md | 5 +++- ogcore/TPI.py | 66 +++++++++++++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ce1456e..ca557a4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 through the existing non-convergence checks instead of spending the rest of `maxiter`. The window check lives in `ogcore.solvers.diagnose_stall` and works for both the picard and - anderson update rules. + anderson update rules. The diagnosis is re-logged if it changes (e.g. + escalates from cycling to diverging), and a run that ends unconverged + while stalled carries the diagnosis in the `RuntimeError` message, so + it reaches users who only see the traceback. ## [0.17.0] - 2026-07-16 12:00:00 diff --git a/ogcore/TPI.py b/ogcore/TPI.py index 2a3c9e26c..da5ff3d6b 100644 --- a/ogcore/TPI.py +++ b/ogcore/TPI.py @@ -931,7 +931,8 @@ def run_TPI(p, client=None): TPIdist = 10 euler_errors = np.zeros((p.T, 2 * p.S, p.J)) TPIdist_vec = np.zeros(p.maxiter) - stall_reported = False + stall = None + stall_reported = None # Pluggable outer-loop update rule. Default "picard" -> None -> the native # damped functional-iteration path below (unchanged, so golden outputs # are preserved); "anderson" accelerates using the residual history. See @@ -1531,34 +1532,32 @@ def run_TPI(p, client=None): # pattern and cannot reach the tolerance -- diagnose the cause and, # if TPI_stall_action="stop", end the loop early (the solution # checks after the loop then fail the run as any non-convergence). - # In the default warn mode the diagnosis is logged once per stall. + # In the default warn mode the diagnosis is logged once per stall, + # and again only if it changes (e.g. escalates to diverging). stall = solvers.diagnose_stall( TPIdist_vec, TPIiter, p.TPI_stall_window ) - if stall is None: - stall_reported = False - else: - if not stall_reported: - stall_reported = True - if stall == "diverging": - logger.error( - "TPI stalled and diverging: the best distance over " - f"the last {p.TPI_stall_window} iterations is far " - "above the earlier best. This usually signals an " - "inconsistent fiscal block (spending, revenue, and " - "debt_ratio_ss), not a solver problem." - ) - else: - logger.error( - "TPI stalled: the best distance has not improved " - f"over the last {p.TPI_stall_window} iterations " - f"(current {TPIdist:.2e}, tolerance " - f"{p.mindist_TPI}). The outer loop is cycling; try " - "a lower nu, or TPI_outer_method='anderson' if not " - "already enabled." - ) - if p.TPI_stall_action == "stop": - break + if stall != stall_reported: + stall_reported = stall + if stall == "diverging": + logger.error( + "TPI stalled and diverging: the best distance over " + f"the last {p.TPI_stall_window} iterations is far " + "above the earlier best. This usually signals an " + "inconsistent fiscal block (spending, revenue, and " + "debt_ratio_ss), not a solver problem." + ) + elif stall == "oscillating": + logger.error( + "TPI stalled: the best distance has not improved " + f"over the last {p.TPI_stall_window} iterations " + f"(current {TPIdist:.2e}, tolerance " + f"{p.mindist_TPI}). The outer loop is cycling; try " + "a lower nu, or TPI_outer_method='anderson' if not " + "already enabled." + ) + if stall is not None and p.TPI_stall_action == "stop": + break # Compute effective and marginal tax rates for all agents num_params = len(p.mtrx_params[0][0]) @@ -1822,9 +1821,18 @@ def run_TPI(p, client=None): if ( (TPIiter >= p.maxiter) or (np.absolute(TPIdist) > p.mindist_TPI) ) and ENFORCE_SOLUTION_CHECKS: - raise RuntimeError( - "Transition path equlibrium not found" + " (TPIdist)" - ) + msg = "Transition path equlibrium not found (TPIdist)" + if stall == "oscillating": + msg += ( + "; the outer loop stalled cycling -- try a lower nu, or " + "TPI_outer_method='anderson'" + ) + elif stall == "diverging": + msg += ( + "; the outer loop stalled diverging -- check the fiscal " + "block (spending, revenue, debt_ratio_ss)" + ) + raise RuntimeError(msg) if (np.any(np.absolute(RC_error) >= p.RC_TPI)) and ENFORCE_SOLUTION_CHECKS: raise RuntimeError(