Solver Callbacks

Monitor solver progress and enforce time limits during optimization
Published

May 28, 2026

1 Overview

Long-running optimizations benefit from real-time visibility into solver progress. Optyx exposes a simple callback interface that delivers a SolverProgress snapshot at every iteration. You can use it to:

  • Log objective value, constraint violation, and elapsed time
  • Terminate early when a solution is “good enough”
  • Enforce a time budget so the solver never runs longer than allowed
  • Combine a callback with a time limit

All three NLP methods (SLSQP, trust-constr, L-BFGS-B) support callbacks and time limits.


2 Part 1: Logging Solver Progress

We minimize a 10-dimension Rosenbrock function and print a status line at every iteration.

from optyx import Problem, SolverProgress, VectorVariable

n = 10
v = VectorVariable("v", n, lb=-5, ub=5)
prob = Problem("rosenbrock")

# Vectorized Rosenbrock — VectorVariable supports slicing
v_head = v[:-1]  # first n-1 elements
v_tail = v[1:]   # last  n-1 elements
obj = ((1 - v_head) ** 2 + 100 * (v_tail - v_head ** 2) ** 2).sum()
prob.minimize(obj)


def log_progress(p: SolverProgress) -> None:
    print(
        f"  iter {p.iteration:3d}  |  obj {p.objective_value:12.4f}"
        f"  |  violation {p.constraint_violation:.2e}"
        f"  |  time {p.elapsed_time:.3f}s"
    )


sol = prob.solve(method="SLSQP", callback=log_progress)
print(f"\nStatus: {sol.status.value}  |  Objective: {sol.objective_value:.6f}")
  iter   1  |  obj  360144.0000  |  violation 0.00e+00  |  time 0.001s
  iter   2  |  obj   65177.2777  |  violation 0.00e+00  |  time 0.001s
  iter   3  |  obj  195283.1899  |  violation 0.00e+00  |  time 0.001s
  iter   4  |  obj  297635.2816  |  violation 0.00e+00  |  time 0.002s
  iter   5  |  obj  311586.0357  |  violation 0.00e+00  |  time 0.002s
  iter   6  |  obj  353866.5635  |  violation 0.00e+00  |  time 0.002s
  iter   7  |  obj  257528.5310  |  violation 0.00e+00  |  time 0.002s
  iter   8  |  obj  206097.6516  |  violation 0.00e+00  |  time 0.002s
  iter   9  |  obj  239567.5517  |  violation 0.00e+00  |  time 0.003s
  iter  10  |  obj   64728.9717  |  violation 0.00e+00  |  time 0.003s
  iter  11  |  obj  114520.5722  |  violation 0.00e+00  |  time 0.003s
  iter  12  |  obj       4.5025  |  violation 0.00e+00  |  time 0.003s
  iter  13  |  obj       4.2049  |  violation 0.00e+00  |  time 0.003s
  iter  14  |  obj       4.1354  |  violation 0.00e+00  |  time 0.003s
  iter  15  |  obj       4.1244  |  violation 0.00e+00  |  time 0.004s
  iter  16  |  obj       4.1233  |  violation 0.00e+00  |  time 0.004s
  iter  17  |  obj       4.1206  |  violation 0.00e+00  |  time 0.004s
  iter  18  |  obj       4.1114  |  violation 0.00e+00  |  time 0.004s
  iter  19  |  obj       4.0894  |  violation 0.00e+00  |  time 0.004s
  iter  20  |  obj       4.0296  |  violation 0.00e+00  |  time 0.004s
  iter  21  |  obj       3.8590  |  violation 0.00e+00  |  time 0.004s
  iter  22  |  obj       4.8255  |  violation 0.00e+00  |  time 0.004s
  iter  23  |  obj     195.1493  |  violation 0.00e+00  |  time 0.005s
  iter  24  |  obj       3.2746  |  violation 0.00e+00  |  time 0.005s
  iter  25  |  obj       2.7195  |  violation 0.00e+00  |  time 0.005s
  iter  26  |  obj       2.5076  |  violation 0.00e+00  |  time 0.005s
  iter  27  |  obj       2.3862  |  violation 0.00e+00  |  time 0.005s
  iter  28  |  obj       2.2280  |  violation 0.00e+00  |  time 0.005s
  iter  29  |  obj       3.1347  |  violation 0.00e+00  |  time 0.005s
  iter  30  |  obj       3.1953  |  violation 0.00e+00  |  time 0.006s
  iter  31  |  obj       1.3750  |  violation 0.00e+00  |  time 0.006s
  iter  32  |  obj       1.2634  |  violation 0.00e+00  |  time 0.006s
  iter  33  |  obj       0.8583  |  violation 0.00e+00  |  time 0.006s
  iter  34  |  obj       0.7933  |  violation 0.00e+00  |  time 0.006s
  iter  35  |  obj       0.5745  |  violation 0.00e+00  |  time 0.007s
  iter  36  |  obj       0.5503  |  violation 0.00e+00  |  time 0.007s
  iter  37  |  obj       0.4323  |  violation 0.00e+00  |  time 0.007s
  iter  38  |  obj       0.3462  |  violation 0.00e+00  |  time 0.007s
  iter  39  |  obj       0.2483  |  violation 0.00e+00  |  time 0.007s
  iter  40  |  obj       0.1748  |  violation 0.00e+00  |  time 0.007s
  iter  41  |  obj       0.1081  |  violation 0.00e+00  |  time 0.007s
  iter  42  |  obj       0.0998  |  violation 0.00e+00  |  time 0.007s
  iter  43  |  obj       0.1668  |  violation 0.00e+00  |  time 0.008s
  iter  44  |  obj       0.1172  |  violation 0.00e+00  |  time 0.008s
  iter  45  |  obj       0.0215  |  violation 0.00e+00  |  time 0.008s
  iter  46  |  obj       0.0132  |  violation 0.00e+00  |  time 0.008s
  iter  47  |  obj       0.0035  |  violation 0.00e+00  |  time 0.008s
  iter  48  |  obj       0.0020  |  violation 0.00e+00  |  time 0.008s
  iter  49  |  obj       0.0007  |  violation 0.00e+00  |  time 0.008s
  iter  50  |  obj       0.0002  |  violation 0.00e+00  |  time 0.008s
  iter  51  |  obj       0.0000  |  violation 0.00e+00  |  time 0.009s
  iter  52  |  obj       0.0000  |  violation 0.00e+00  |  time 0.009s
  iter  53  |  obj       0.0000  |  violation 0.00e+00  |  time 0.009s
  iter  54  |  obj       0.0000  |  violation 0.00e+00  |  time 0.009s

Status: optimal  |  Objective: 0.000000

The SolverProgress object contains five fields:

Field Type Description
iteration int Current iteration number (1-based)
objective_value float Objective in the original sense
constraint_violation float Max constraint violation (0 if feasible)
elapsed_time float Wall-clock seconds since solve started
x np.ndarray Current variable values

3 Part 2: Early Termination

Return True from the callback to stop the solver. The solution will have status SolverStatus.TERMINATED.

prob.reset()  # clear warm start so we see more iterations

THRESHOLD = 1.0

def stop_when_good_enough(p: SolverProgress) -> bool:
    if p.objective_value < THRESHOLD:
        print(f"  Objective {p.objective_value:.4f} < {THRESHOLD} — stopping early")
        return True
    return False

sol = prob.solve(method="SLSQP", callback=stop_when_good_enough)
print(f"Status: {sol.status.value}  |  Objective: {sol.objective_value:.6f}")
  Objective 0.8583 < 1.0 — stopping early
Status: terminated  |  Objective: 0.858335

Returning None or False lets the solver continue normally.


4 Part 3: Time Limits

Pass time_limit= (in seconds) to cap the wall-clock duration. This is useful for production systems that must respond within a deadline.

prob.reset()

sol = prob.solve(method="SLSQP", time_limit=0.005)
print(f"Status: {sol.status.value}")
print(f"Objective: {sol.objective_value:.6f}")
print(f"Solve time: {sol.solve_time:.4f}s  |  Iterations: {sol.iterations}")
Status: terminated
Objective: 0.550348
Solve time: 0.0051s  |  Iterations: 36

5 Part 4: Combining Callback and Time Limit

Both mechanisms work together — whichever fires first terminates the solve.

prob.reset()

history: list[float] = []

def record_objective(p: SolverProgress) -> None:
    history.append(p.objective_value)

sol = prob.solve(method="SLSQP", callback=record_objective, time_limit=0.05)
print(f"Status: {sol.status.value}  |  Objective: {sol.objective_value:.6f}")
print(f"Recorded {len(history)} objective snapshots")
Status: optimal  |  Objective: 0.000000
Recorded 54 objective snapshots

6 Key Points

  • callback receives a SolverProgress each iteration; return True to stop.
  • time_limit is a wall-clock budget in seconds.
  • Early-terminated solutions still contain variable values, objective, and solve time — they just carry SolverStatus.TERMINATED instead of OPTIMAL.
  • Both parameters are supported by all NLP methods (SLSQP, trust-constr, L-BFGS-B).