Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
08308d7
Add trace_run recipe for structured trace output
MySweetEden Jan 19, 2026
408b6da
Refine trace_run recipe behavior
MySweetEden Jan 20, 2026
868022f
Add tests for trace_run recipe
MySweetEden Jan 20, 2026
9d2dfac
Rename tests for trace_run recipe
MySweetEden Jan 20, 2026
4ab23b2
Refactor trace_run recipe to enhance event handling and optimize func…
MySweetEden Jan 22, 2026
1f1bead
Enhance _TraceRun class to include snapshot logging and improve event…
MySweetEden Jan 23, 2026
2c9444c
Refactor _TraceRun class to replace snapshot logging methods with a u…
MySweetEden Jan 23, 2026
211359a
Refactor tests for trace_run recipe to use parameterized optimize fun…
MySweetEden Jan 23, 2026
2e8f3bd
Add docstring to _TraceRun class for real-time optimization progress …
MySweetEden Jan 23, 2026
afbe9e0
Add realtime_trace_jsonl recipe for real-time optimization progress t…
MySweetEden Jan 23, 2026
6bf7355
Update usage examples in _TraceRun class docstring to use keyword arg…
MySweetEden Jan 24, 2026
cc41cad
Merge branch 'master' into realtime-trace-jsonl
MySweetEden Jan 24, 2026
f646e51
Merge branch 'master' into realtime-trace-jsonl
MySweetEden Jan 26, 2026
b822dbc
Refactor event handling in _TraceRun class to improve clarity and mai…
MySweetEden Jan 31, 2026
7fd53cf
Merge remote-tracking branch 'origin/master' into realtime-trace-jsonl
MySweetEden Jan 31, 2026
987251f
Enhance comments in _TraceRun class for clarity on event handling and…
MySweetEden Jan 31, 2026
3478efb
Merge remote-tracking branch 'origin/master' into realtime-trace-jsonl
MySweetEden Feb 2, 2026
fc869bc
Merge branch 'master' into realtime-trace-jsonl
MySweetEden Feb 2, 2026
c7d36b9
Merge remote-tracking branch 'origin/master' into realtime-trace-jsonl
MySweetEden Feb 3, 2026
7e81977
Merge remote-tracking branch 'origin/master' into realtime-trace-jsonl
MySweetEden Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Added `structured_optimization_trace` recipe for structured optimization progress tracking
- Added methods: `getPrimalDualIntegral()`
- `getSolVal()` supports `MatrixExpr` now
- Added `realtime_trace_jsonl` recipe for real-time optimization progress tracking with JSONL streaming output
### Fixed
- `getBestSol()` now returns `None` for infeasible problems instead of a `Solution` with `NULL` pointer
- all fundamental callbacks now raise an error if not implemented
Expand Down
139 changes: 139 additions & 0 deletions src/pyscipopt/recipes/realtime_trace_jsonl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import json

from pyscipopt import SCIP_EVENTTYPE, Eventhdlr


class _TraceRun:
"""
Record optimization progress in real time while the solver is running.

Args
----
model: pyscipopt.Model
path: str | None
- None: in-memory only
- str : also write JSONL (one JSON object per line) for streaming/real-time consumption

Returns
-------
None
Updates `model.data["trace"]` as a side effect.

Usage
-----
optimizeTrace(model) # real-time in-memory trace
optimizeTrace(model, path="trace.jsonl") # real-time JSONL stream + in-memory
optimizeNogilTrace(model, path="trace.jsonl") # nogil variant
"""

def __init__(self, model, path=None):
self.model = model
self.path = path
self._fh = None
self._handler = None
self._caught_events = set()
self._last_snapshot = {}

def __enter__(self):
if not hasattr(self.model, "data") or self.model.data is None:
self.model.data = {}
self.model.data["trace"] = []

if self.path is not None:
self._fh = open(self.path, "w")

class _TraceEventhdlr(Eventhdlr):
def eventinit(hdlr):
for et in (
SCIP_EVENTTYPE.BESTSOLFOUND,
SCIP_EVENTTYPE.DUALBOUNDIMPROVED,
):
self.model.catchEvent(et, hdlr)
self._caught_events.add(et)

def eventexec(hdlr, event):
et = event.getType()
if et == SCIP_EVENTTYPE.BESTSOLFOUND:
snapshot = self._snapshot_now()
self._last_snapshot = snapshot
self._write_event("bestsol_found", fields=snapshot, flush=True)
elif et == SCIP_EVENTTYPE.DUALBOUNDIMPROVED:
snapshot = self._snapshot_now()
self._last_snapshot = snapshot
# Flush disabled: frequent event; OS buffering suffices
self._write_event(
"dualbound_improved", fields=snapshot, flush=False
)
Comment on lines +64 to +66
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a recipe marketed as “real-time JSONL streaming”, not flushing dualbound_improved events can delay visibility for external consumers tailing the file. Consider flushing here as well (or making flushing policy configurable), especially since dualbound_improved is one of the primary progress signals you record.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not Addressed

2. dualbound_improved flush policy

dualbound_improved events are intentionally not flushed:

  • Frequency asymmetry: dualbound_improved fires hundreds to thousands of times during optimization, while bestsol_found fires only a few dozen times at most; flushing on every dual bound update would accumulate significant I/O overhead
  • OS buffering suffices: Events naturally flush within seconds via OS buffering, providing adequate real-time visibility
  • Context: Optimizations typically run for minutes to hours, making second-scale buffering delays negligible

Discussion: I'm open to reconsidering the flush policy if there are use cases where immediate flushing of dualbound_improved events is valuable (e.g., sub-minute monitoring). Would making it configurable be useful, or is the current approach acceptable?


self._handler = _TraceEventhdlr()
self.model.includeEventhdlr(
self._handler, "realtime_trace_jsonl", "Realtime trace jsonl handler"
)
Comment on lines +68 to +71
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

includeEventhdlr() registers an event handler plugin permanently (there is no corresponding remove/uninclude API). Calling optimizeTrace()/optimizeNogilTrace() multiple times on the same model will attempt to include another handler with the same name (realtime_trace_jsonl), which can raise a SCIP error and/or leave multiple live handlers capturing closed file handles and old _TraceRun instances. Refactor to include the handler at most once per model (e.g., stash/reuse it in model.data), and make the handler read its current sink (trace list / file handle) from mutable attributes rather than a closure over a per-run object.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

@MySweetEden MySweetEden Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not Addressed

1. includeEventhdlr() multiple invocation issue

The concern about permanent handler registration is valid:

  • includeEventhdlr() registers handlers permanently with no removal API
  • dropEvent() only unsubscribes from events, not the handler itself

Scope: Refactoring to a handler-reuse pattern would require architectural changes and will be addressed separately. The current implementation assumes single-run usage.


return self

def __exit__(self, exc_type, exc, tb):
fields = {}
if self._last_snapshot:
fields.update(self._last_snapshot)

if exc_type is not None:
fields.update(
{
"status": "exception",
"exception": exc_type.__name__,
"message": str(exc) if exc is not None else None,
}
)

try:
self._write_event("run_end", fields=fields, flush=True)
finally:
if self._fh:
try:
self._fh.close()
finally:
self._fh = None

if self._handler is not None:
for et in self._caught_events:
try:
self.model.dropEvent(et, self._handler)
except Exception:
# Best-effort cleanup; continue dropping remaining events
pass
self._caught_events.clear()
self._handler = None

return False

def _snapshot_now(self) -> dict:
return {
"time": self.model.getSolvingTime(),
"primalbound": self.model.getPrimalbound(),
"dualbound": self.model.getDualbound(),
"gap": self.model.getGap(),
"nodes": self.model.getNNodes(),
"nsol": self.model.getNSols(),
}

def _write_event(self, event_type, fields=None, flush=True):
event = {"type": event_type}
if fields:
event.update(fields)

self.model.data["trace"].append(event)
if self._fh is not None:
self._fh.write(json.dumps(event) + "\n")
if flush:
self._fh.flush()


def optimizeTrace(model, path=None):
with _TraceRun(model, path):
model.optimize()


def optimizeNogilTrace(model, path=None):
with _TraceRun(model, path):
model.optimizeNogil()
87 changes: 87 additions & 0 deletions tests/test_recipe_realtime_trace_jsonl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import json
from random import randint

import pytest
from helpers.utils import bin_packing_model

from pyscipopt import SCIP_EVENTTYPE, Eventhdlr
from pyscipopt.recipes.realtime_trace_jsonl import optimizeNogilTrace, optimizeTrace


@pytest.fixture(
params=[optimizeTrace, optimizeNogilTrace], ids=["optimize", "optimize_nogil"]
)
def optimize(request):
return request.param


def test_realtime_trace_in_memory(optimize):
model = bin_packing_model(sizes=[randint(1, 40) for _ in range(120)], capacity=50)
model.setParam("limits/time", 5)

model.data = {"test": True}

optimize(model, path=None)

assert "test" in model.data
assert "trace" in model.data

required_fields = {"time", "primalbound", "dualbound", "gap", "nodes", "nsol"}

types = [r["type"] for r in model.data["trace"]]
assert ("bestsol_found" in types) or ("dualbound_improved" in types)

for record in model.data["trace"]:
if record["type"] != "run_end":
assert required_fields <= set(record.keys())

primalbounds = [r["primalbound"] for r in model.data["trace"] if "primalbound" in r]
for i in range(1, len(primalbounds)):
assert primalbounds[i] <= primalbounds[i - 1]

dualbounds = [r["dualbound"] for r in model.data["trace"] if "dualbound" in r]
for i in range(1, len(dualbounds)):
assert dualbounds[i] >= dualbounds[i - 1]

assert "run_end" in types


def test_realtime_trace_file_output(optimize, tmp_path):
model = bin_packing_model(sizes=[randint(1, 40) for _ in range(120)], capacity=50)
model.setParam("limits/time", 5)

path = tmp_path / "trace.jsonl"

optimize(model, path=str(path))

assert path.exists()

records = [json.loads(line) for line in path.read_text().splitlines()]
assert len(records) > 0

types = [r["type"] for r in records]
assert "run_end" in types


class _InterruptOnBest(Eventhdlr):
def eventinit(self):
self.model.catchEvent(SCIP_EVENTTYPE.BESTSOLFOUND, self)

def eventexec(self, event):
self.model.interruptSolve()


def test_optimize_with_trace_records_run_end_on_interrupt(optimize):
model = bin_packing_model(
sizes=[randint(1, 40) for _ in range(120)],
capacity=50,
)
model.setParam("limits/time", 5)

model.includeEventhdlr(_InterruptOnBest(), "stopper", "Interrupt on bestsol")

optimize(model, path=None)

types = [r["type"] for r in model.data["trace"]]
assert "bestsol_found" in types
assert "run_end" in types
Loading