From 4c32a653efa5296b1ce80c283ab1aaadafbfea6c Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 14 Sep 2026 16:26:27 +0200 Subject: [PATCH] feat: dump requests the capped cost stage left unproven With the cost stage capped nothing exhausts the time limit any more, so the slow request dump stopped filling. A request now also lands in it when the cost stage gap is above one currency unit, and the dump line carries that gap, so the replay corpus holds exactly the requests where a longer clock might have bought money. --- src/optimizer/app.py | 19 ++++++++++++++----- src/optimizer/settings.py | 3 ++- tests/test_app.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/optimizer/app.py b/src/optimizer/app.py index 6cf057c8b..9cd310624 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -46,20 +46,29 @@ def money(value): return None if value is None else round(value, 4) -def dump_slow_request(payload, elapsed): - """Persist requests that exhausted the solver time limit, they are the ones worth replaying. +# money the capped cost stage may leave unproven between its schedule and CBC's bound before +# the request is dumped for replay. The cap keeps requests short of the time limit, so the gap +# is what marks the ones worth replaying at a longer clock. +DUMP_GAP = 1.0 + + +def dump_slow_request(payload, elapsed, gap): + """Persist requests worth replaying: those that exhausted the solver time limit, and those + the cost stage left more than DUMP_GAP of money unproven on. The elapsed time covers model building as well as solving, so a request that only exceeds the limit while building is caught too. That one is equally worth looking at. """ path, limit = settings.dump_slow_requests, settings.time_limit - if not path or limit is None or elapsed < limit: + slow = limit is not None and elapsed >= limit + unproven = gap is not None and gap > DUMP_GAP + if not path or not (slow or unproven): return # one line per request, carrying the same "request" key as test_cases/*.json so a line # can be replayed by the existing harness line = json.dumps({"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), - "elapsed": round(elapsed, 3), "request": payload}) + "\n" + "elapsed": round(elapsed, 3), "gap": money(gap), "request": payload}) + "\n" try: pathlib.Path(path).parent.mkdir(parents=True, exist_ok=True) with open(path, "a") as f: @@ -287,7 +296,7 @@ def post(self): "steps": optimizer.T, }}), flush=True) - dump_slow_request(data, elapsed) + dump_slow_request(data, elapsed, optimizer.cost_stage_gap) return result except Exception as e: diff --git a/src/optimizer/settings.py b/src/optimizer/settings.py index 39c3ebf1b..34f72ddb1 100644 --- a/src/optimizer/settings.py +++ b/src/optimizer/settings.py @@ -21,4 +21,5 @@ class OptimizerSettings(BaseSettings): time_limit: float | None = Field(default=None, description="Time limit for the optimization process in seconds") log_subject: bool = Field(default=False, description="Log the JWT subject of every request. Off by default, it names accounts in the logs") dump_slow_requests: str | None = Field(default=None, - description="JSON Lines file requests that exhaust the solver time limit are appended to. Unset disables the dump") + description="JSON Lines file requests that exhaust the solver time limit, or leave more than one currency " + "unit unproven in the cost stage, are appended to. Unset disables the dump") diff --git a/tests/test_app.py b/tests/test_app.py index a442e1840..52325c1bb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,6 +7,7 @@ import pulp import pytest +import optimizer.app as app_module from optimizer.app import app, settings @@ -138,6 +139,25 @@ def test_slow_requests_are_dumped(tmp_path, monkeypatch): assert json.loads(lines[1])["elapsed"] > 0 +def test_requests_with_an_open_gap_are_dumped(tmp_path, monkeypatch): + request = json.loads(pathlib.Path('test_cases/026-attenuate-grid-peaks.json').read_text())["request"] + dump = tmp_path / "gap.jsonl" + client = app.test_client() + monkeypatch.setattr(settings, "dump_slow_requests", str(dump)) + # the solver reads its own settings from the environment, so force the split there + monkeypatch.setenv("OPTIMIZER_PROBE_SECONDS", "0") + + # a proven cost stage has no gap to replay for + client.post("/optimize/charge-schedule", json=request) + assert not dump.exists() + + monkeypatch.setattr(app_module, "DUMP_GAP", -1.0) + client.post("/optimize/charge-schedule", json=request) + line = json.loads(dump.read_text()) + assert line["gap"] == 0 + assert line["request"] == request + + def test_every_request_logs_a_solve_line(capsys): # the key names are the Log Analytics contract: the dashboard's KQL queries parse this line, # so renaming one breaks production attribution silently