From 21d0f947e3b001d54f6512519e5765e1da5dec43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 25 Oct 2026 01:13:38 +0100 Subject: [PATCH 1/2] Grow the trust region for stepped-back steps that hit the radius update_tr_radius only increased the radius for steps with norm >= 0.9 * delta. Steps that were stepped back to stay within the bounds are shorter than the radius because of the bounds, so the radius never grew for them, even at tr_ratio ~1. With reflect_single and several active bounds, every step is a single reflection cut short by the next bound and runs crawled to MAXITER. Decide whether the trust region constraint was active based on the proposal before step back (og_ss + ss0). This is unchanged for steps that were not stepped back. Steps whose proposal was interior to the trust region (e.g. a Newton step that hits a bound) still do not increase the radius, which avoids unbounded radius growth while steps are bound-limited. Fixes #78 Co-Authored-By: Claude Opus 5.5 --- fides/minimize.py | 10 +++++++++- tests/test_minimize.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fides/minimize.py b/fides/minimize.py index ca31023..203b947 100644 --- a/fides/minimize.py +++ b/fides/minimize.py @@ -688,7 +688,15 @@ def update_tr_radius( else: self.tr_ratio = actual_decrease / predicted_decrease - interior_solution = nsx < self.delta_iter * 0.9 + # the radius is only increased if the trust region constraint + # was active. For steps that were stepped back to stay within + # the bounds (step.alpha < 1), this is decided based on the + # proposal before step back. The stepped back step is short + # because of the bounds, not the trust region, e.g., a single + # reflection that is cut short by the next bound. + interior_solution = ( + norm(step.og_ss + step.ss0) < self.delta_iter * 0.9 + ) if ( self.tr_ratio >= self.get_option(Options.ETA) diff --git a/tests/test_minimize.py b/tests/test_minimize.py index 9796530..a6f8a03 100644 --- a/tests/test_minimize.py +++ b/tests/test_minimize.py @@ -1398,3 +1398,44 @@ def test_reflected_step_roundoff_stays_in_bounds(subspace_dim): assert np.all(x <= ub) assert opt.exitflag > 0 assert np.isclose(fval, 10.913558700882975, rtol=1e-6) + + +@pytest.mark.parametrize( + 'stepback', + [ + StepBackStrategy.SINGLE_REFLECT, + StepBackStrategy.REFLECT, + StepBackStrategy.TRUNCATE, + StepBackStrategy.MIXED, + ], +) +@pytest.mark.parametrize('subspace_dim', [SubSpaceDim.TWO, SubSpaceDim.FULL]) +def test_several_active_bounds(subspace_dim, stepback): + """ + gh-78: with reflect_single, the reflected segment is cut short by the + second active bound. The step is then shorter than the radius, the + radius was never increased and the run crawled to MAXITER. + """ + hess = np.diag([2.0, 1.0, 1.5, 1.2]) + c = np.array([-1000.0, 1000.0, -1000.0, 1000.0]) + + def fun(x): + d = x - c + g = hess @ d + return 0.5 * d @ g, g, hess + + opt = Optimizer( + fun, + ub=np.full(4, np.inf), + lb=np.array([-1.0, -np.inf, -1.0, -np.inf]), + verbose=logging.WARNING, + options={ + Options.MAXITER: 100, + Options.SUBSPACE_DIM: subspace_dim, + Options.STEPBACK_STRAT: stepback, + }, + ) + fval, x, _, _ = opt.minimize(np.array([-1.0, 0.0, -1.0, 0.0])) + assert opt.exitflag > 0 + assert np.isclose(fval, 1746501.75, rtol=1e-8) + assert np.allclose(x, [-1.0, 1000.0, -1.0, 1000.0], rtol=1e-6) From 91d78d8eef874bcddde9a9fe038f1a50eb6a51e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Fr=C3=B6hlich?= Date: Sun, 25 Oct 2026 01:09:21 +0000 Subject: [PATCH 2/2] Cover refine in the several-active-bounds regression test 2D/refine also crawled to MAXITER on the gh-78 reproducer before the radius fix. Co-Authored-By: Claude Opus 5.5 --- tests/test_minimize.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/test_minimize.py b/tests/test_minimize.py index 7c90319..683e011 100644 --- a/tests/test_minimize.py +++ b/tests/test_minimize.py @@ -1539,15 +1539,7 @@ def test_reflected_step_roundoff_stays_in_bounds(subspace_dim): assert np.isclose(fval, 10.913558700882975, rtol=1e-6) -@pytest.mark.parametrize( - 'stepback', - [ - StepBackStrategy.SINGLE_REFLECT, - StepBackStrategy.REFLECT, - StepBackStrategy.TRUNCATE, - StepBackStrategy.MIXED, - ], -) +@pytest.mark.parametrize('stepback', list(StepBackStrategy)) @pytest.mark.parametrize('subspace_dim', [SubSpaceDim.TWO, SubSpaceDim.FULL]) def test_several_active_bounds(subspace_dim, stepback): """ @@ -1575,6 +1567,8 @@ def fun(x): }, ) fval, x, _, _ = opt.minimize(np.array([-1.0, 0.0, -1.0, 0.0])) - assert opt.exitflag > 0 + # full/refine stops with DELTA_TOO_SMALL at the optimum, as + # the predicted decrease there is below the resolution of fval + assert opt.exitflag != fides.ExitFlag.MAXITER assert np.isclose(fval, 1746501.75, rtol=1e-8) - assert np.allclose(x, [-1.0, 1000.0, -1.0, 1000.0], rtol=1e-6) + assert np.allclose(x, [-1.0, 1000.0, -1.0, 1000.0], rtol=1e-5)