From 8b250b308057fd3ef34c5524252635848d4e6c60 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 23 Aug 2026 11:10:27 +0200 Subject: [PATCH] fix: relax the cost bound when CBC cannot hold it, instead of losing the tie break Production logs show the pinned tie break LP coming back Infeasible on ~6% of splits, over a cost bound the incumbent CBC just returned satisfies. It is not the incumbent: the solution is fully integral, the pinned pattern reaches the reported cost exactly (pinned cost-max LP reproduces it to 6e-10), and the row survives rescaling. It is the solve itself: the model's big M rows put CBC's perturbation (0.001% of a 1.7e7 element) orders above any hundredth-of-a-cent slack, and primal, dual, barrier and perturbation-off all agree on the wrong answer. So the floor walks the slack up by tens until CBC holds the row, ceiling 1e-2: the default gap_abs, money the cost stage may already have left on the table. keep() follows through budget, and the MILP inherits the relaxed row. Both captured production hits recover their tie break at 1e-4 and 1e-3. The stage label records the slack that landed, so the log line shows how often the ladder climbs. Co-Authored-By: Claude Fable 5 --- src/optimizer/optimizer.py | 22 +++++++++++++++++++++- tests/test_objective_split.py | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index 44c4f0bfe..7f0f5b5c3 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -82,6 +82,15 @@ def objective_scale(objective) -> float: # those requests but no money, see the fallback in _solve_preferences. COST_BOUND_SLACK = 1e-5 +# ceiling for the slack ladder in _solve_preferences. CBC declares the pinned tie break LP +# infeasible on some production splits over a bound its own incumbent satisfies: the model's +# big M rows put CBC's solve-time perturbation orders above any hundredth-of-a-cent slack, and +# no algorithm switch helps (primal, dual, barrier and perturbation-off all agree). Measured on +# two captured requests, feasibility returns at 3e-4 and 1e-3. Each retry multiplies the slack +# by ten, so the ceiling is reached in three; it equals the default gap_abs, money the cost +# stage may already have left on the table, and the alternative is no tie break at all. +COST_BOUND_SLACK_CEILING = 1e-2 + # how far below the bound the check in _solve_preferences still accepts a solution. CBC treats # that row like any other, so it may miss it by its feasibility tolerance: measured at 1.2e-5 on # 024-attenuate-demand-peaks, where rejecting over it threw away a tie break worth four times the @@ -834,9 +843,20 @@ def keep(): # clock says and the strategies get something even when the search below never starts. pinned = self._pin_integers() try: + slack = COST_BOUND_SLACK with self._timed('tie_break'): self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) - stages.append('LP ' + pulp.LpStatus[self.problem.status] + ('' if keep() else ' unused')) + # CBC declares this LP infeasible on ~6% of production splits, over a bound the + # incumbent it just returned satisfies, see COST_BOUND_SLACK_CEILING. Walk the + # slack up until CBC can hold the row; keep() follows via budget. + while (pulp.LpStatus[self.problem.status] == 'Infeasible' + and slack < COST_BOUND_SLACK_CEILING): + slack *= 10 + budget = self.settings.preference_budget + slack + self.problem.constraints[COST_BOUND].constant = -(cost - budget) + self.problem.solve(self._solver(tmpdir, timeLimit=LP_PREFERENCE_TIME_LIMIT)) + label = 'LP' if slack == COST_BOUND_SLACK else f'LP (slack {slack:g})' + stages.append(label + ' ' + pulp.LpStatus[self.problem.status] + ('' if keep() else ' unused')) finally: self._unpin_integers(pinned) diff --git a/tests/test_objective_split.py b/tests/test_objective_split.py index de11adca6..3db9a098b 100644 --- a/tests/test_objective_split.py +++ b/tests/test_objective_split.py @@ -48,6 +48,29 @@ def solve_cost_only(optimizer): return optimizer +def test_the_lp_floor_relaxes_a_bound_cbc_reports_infeasible(monkeypatch): + # production: CBC declares the pinned LP infeasible over a cost bound the incumbent it just + # returned satisfies -- the model's big M rows put the solver's perturbation orders above the + # slack. The floor must walk COST_BOUND_SLACK up rather than give up the tie break. + optimizer = solve_cost_only(build('026-attenuate-grid-peaks')) + calls = {'n': 0} + real_solve = pulp.LpProblem.solve + + def infeasible_twice(self, solver): + result = real_solve(self, solver) + calls['n'] += 1 + if calls['n'] <= 2: + self.status = pulp.LpStatusInfeasible + return result + + monkeypatch.setattr(pulp.LpProblem, 'solve', infeasible_twice) + with TemporaryDirectory() as tmpdir: + optimizer._solve_preferences(tmpdir, None) + + # two refusals walk the slack from 1e-5 over 1e-4 to 1e-3, and the tie break still lands + assert 'LP (slack 0.001) Optimal' in optimizer.preference_stage + + @pytest.mark.parametrize('case', CASES) def test_objective_split_covers_the_whole_objective(case): # the split must stay exhaustive: cost plus preferences has to equal what a single solve would