Skip to content

Roll up release - #135

Closed
andig wants to merge 5 commits into
mainfrom
rollup/two-stage-plus-130
Closed

Roll up release#135
andig wants to merge 5 commits into
mainfrom
rollup/two-stage-plus-130

Conversation

@andig

@andig andig commented Aug 7, 2026

Copy link
Copy Markdown
Member

Rollup branch off main combining:

Merge conflicts resolved in two places, both from the same overlap: #130 rewrote the leveling terms that perf/two-stage-solve had moved into the preference objective.

Full test suite passes (124 passed).

@andig
andig marked this pull request as draft August 7, 2026 08:13
@andig andig changed the title Roll up the two stage solve and the level deviation leveling Roll up release Aug 8, 2026
andig and others added 5 commits August 8, 2026 15:31
The cost neutral tie breakers, not the physics and not the horizon, drive the
requests that exhaust OPTIMIZER_TIME_LIMIT. On a flat tariff the cost optimum is
a plateau of equally priced schedules, and the strategy terms are far too small
to decide between them, so the search walks the plateau instead.

OPTIMIZER_GAP_ABS, one cent, stops the search once the remaining gap is worth
less than a cent. OPTIMIZER_STRATEGY_WEIGHT, 3, lifts the strategy terms far
enough above the cost terms to actually decide the tie. Neither is enough alone:
the gap collapses the plateau walk but leaves the tie to wherever the search
stopped, the weight decides the tie but still searches.

To support the weight, the objective is assembled as cost_objective plus
preference_objective, with a test asserting the split stays exhaustive.
OPTIMIZER_GAP_ABS bounds the whole objective, preferences included, and the
preferences are orders below any useful gap: measured over the golden cases the
entire weighted preference objective is worth less than one cent on 8 of 19, by
up to 149x on 018-high-soc-initial. Inside one objective the gap therefore
swallows the tie break, and OPTIMIZER_STRATEGY_WEIGHT cannot buy it back: 3
gives a factor 3 against a shortfall of 3 to 150, and 10 already costs 0.4
percent of real money on 023.

So the two are solved in sequence instead. The first stage maximizes
cost_objective and may stop OPTIMIZER_GAP_ABS short, which is where the speed
comes from. The second maximizes preference_objective under a constraint that
keeps the money the first stage found, which is where the strategies are
decided. Strategy adherence becomes something the model solves for rather than
something that emerges from the ratio between coefficients, so the weight and
its cliff table are gone.

OPTIMIZER_PREFERENCE_BUDGET replaces the weight and defaults to zero, the cost
neutrality the strategies are documented to have. It is not a knob to reach for:
the second stage is indifferent to money below the bound, so every unit granted
is spent, measured exactly at budgets of 1 and 100.

Both stages share one wall clock, so the second solve cannot double the response
time, and anything short of Optimal there falls back to the first stage schedule
whole. A timed out tie break costs preference, never money.

019-unexpected-charge-spikes is refreshed. Its money is unchanged to the last
digit, only the reported objective_value moves, the s[0] artifact documented in
AGENTS.md. test_app gains an atol of a hundredth of a cent, below which the
reported value only carries the slack the cost bound needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the slow request dump brought out, none of them visible on the
golden cases alone.

CBC returns a strictly worse schedule and reports it as proven optimal when the
first stage solution is handed to it as a MIP start, and it calls the model
infeasible over a cost bound that same solution satisfies. Measured on request
4f28656db0ba: preference -0.806 warm against -0.610 cold, where cold matches a
single joint solve to the last digit. No warm start, and the infeasible runs
that motivated turning presolve off disappear with it, so presolve goes back to
its default.

Deciding the tie to proven optimality is its own hard problem, as expensive as
the cost optimum on exactly the requests this is meant to help: solved cold to
the end it puts the median back to 10.05 s over the dump. So the stage gets a
quarter of the time limit rather than whatever is left, and what it does not
finish is kept anyway. A tie break stopped by the clock still holds an
incumbent, and the alternative is the first stage schedule, which is no tie
break at all. The improvement and the money are checked here rather than read
off the solver status.

That check needs a tolerance of its own. CBC treats the cost bound like any
other row and misses it by its feasibility tolerance, 1.2e-5 on
024-attenuate-demand-peaks, where rejecting over it threw away a tie break worth
four times the peak. It is also what 024 needs: levelling a peak costs round
trip efficiency, about 3.5e-5 there, so the strategies are not quite the cost
neutral tie breakers the docs claim.

019-unexpected-charge-spikes is back to its stored expectation, no golden case
moves. test_app keeps an atol of a hundredth of a cent, which is what the bound
gives away.

Over 335 unique requests from the slow request dump, at the production limit of
10 s: median 7.65 s to 2.99 s, requests over 10 s 149 to 52, and against main
the strategies come out ahead on 120 requests and behind on 79, where the
weighted variant of the previous commit is behind on 112 and ahead on 51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split costs a second CBC spawn on every request with a strategy set, and
buys nothing on a request that was never degenerate: a model that proves the
joint objective decided its own tie, in one solve, with no gap and no
preference budget to give anything away. Measured on the stored cases, that is
a median of 0.114 s against 0.052 s on main, a doubling paid by all traffic to
help the one percent that reaches the time limit.

So probe first. Solve the whole objective with no gap on a fifth of the time
limit; if it proves optimality, stop there. Only a probe that runs out falls
back to the money-then-preferences split, which is the case the split was built
for. On the stored cases the median returns to main's 0.052 s, and
020-weird-charging-at-night gets faster than either, 0.53 s against 0.93 s,
because the probe correctly routes it to the split.

sol_status, not status: pulp reports LpStatusOptimal whenever CBC returns any
feasible solution, including one it stopped on at the time limit. On a captured
request a 2 s and a 30 s run both said Optimal, with objectives of -682466848
and 59714881. Reading status alone made the probe accept every truncated search
and quietly cut the whole population off at the probe budget.

Over 335 captured slow requests the probe is a loss, as it should be on a
population that is entirely hard: median 4.89 s against 3.03 s for the
unconditional split, with 214 of 335 falling through. Requests over the limit
stay far below main, 51 against 149.

Known gap: one request of the 335 comes back Not Solved, where the probe finds
no feasible solution and leaves the cost stage too little clock to find one
either. A shorter probe does not fix it. The probe's incumbent is kept when it
has one, which covers the rest.

OPTIMIZER_PROBE_SECONDS tunes the budget, 0 restores the unconditional split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second stage decides whether to keep its result by reading the variables
back, and a solve that ran out of clock before it found an integer solution
leaves the relaxation in them. That point scores better on the preferences than
any real schedule can, because it is one the model forbids: the binaries land
between 0 and 1 and every rule they gate stops holding. So the tie break kept
it, overwrote the first stage schedule, and the response went out with the
charge power gate ignored in a third of its slots and a status of Feasible.

Reported in #127, where an EV with c_min=4140 charged at a fifth of that in 14
of 192 slots, z_c sitting between 0.12 and 0.36. It reproduces on the split
path whenever the preference stage finds nothing before its slice runs out.

Integrality is now checked where the other two acceptance conditions already
are, for the reason stated there: a solver that reports the wrong status must
not be able to spend money, and it must not be able to hand back a schedule the
model does not allow either.

A second check sits right before the result is built, standing in for every
stage above deciding correctly. A schedule that breaks the model is worse than
no schedule, since it looks like an answer and the caller charges a battery by
it, so a fractional point is reported as no schedule at all rather than
returned. One pass over the integer variables against a solve measured in
seconds.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andig
andig force-pushed the rollup/two-stage-plus-130 branch from 0397ee4 to 6ab54bd Compare August 8, 2026 14:12
@andig

andig commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

All merged.

@andig andig closed this Aug 9, 2026
@andig
andig deleted the rollup/two-stage-plus-130 branch August 9, 2026 14:26
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.

1 participant