Documentation

SolverManager

Run retained solver jobs with lifecycle-complete streaming, snapshots, pause/resume, and problem changes.

SolverManager is the retained runtime API for running solver jobs. It owns the authoritative lifecycle state, streams SolverEvent values, retains snapshots for later inspection, and exposes in-process pause/resume, cancellation, and real-time problem changes.

Creating a SolverManager

SolverManager::new() is a const fn that takes no arguments. It’s designed to be used as a static. It is not in the prelude; import it from the crate root:

use solverforge::SolverManager;

static MANAGER: SolverManager<Schedule> = SolverManager::new();

Solving

Call .solve() with your planning solution. It returns a Result containing a (job_id, SolverEventReceiver) tuple:

let (job_id, rx) = MANAGER.solve(solution).expect("solver job should start");

The receiver yields SolverEvent<S> values, not raw (solution, score) tuples: blocking_recv() waits on a plain thread, recv().await waits in async code, and try_recv() returns a buffered event without waiting. Each event carries SolverEventMetadata with the job id, event sequence, lifecycle state, telemetry, current/best score, and the latest snapshot_revision when one exists.

In the current runtime surface, retained telemetry keeps exact generated, evaluated, accepted, not-doable, acceptor-rejected, forager-ignored, hard-improving, hard-neutral, hard-worse, conflict-repair, and construction-slot counts together with generation and evaluation durations. SelectorTelemetry also carries selector_index and selector_label so local-search and VND diagnostics can tie those counters back to the active neighborhood. MoveTelemetry aggregates the same search path by static move label, and the bounded applied-move trace records the first applied move steps with selected candidate index, per-step generated/evaluated/accepted counts, ignored accepted moves, score delta, and hard feasibility before and after the move. Any displayed moves/s value is derived at the edge rather than stored as the canonical runtime metric.

SolverTelemetry::phases contains one PhaseTelemetry for every phase started, in execution order. Each entry carries phase_index, phase_type, active elapsed time, step, move, score-calculation, generation-time, and evaluation-time counters; the last entry of a progress snapshot is the running phase. Completed phase entries remain in terminal telemetry. Run totals include the phase sum plus work outside phases, such as the initial score calculation. moves_speculative appears at run and phase level for parallel trials discarded after the step’s decision point; it is zero for sequential evaluation and is separate from moves_evaluated and score_calculations. A generated count means a candidate was actually yielded to the engine; it does not include an unrequested logical tail after a cursor or forager short-circuits. Construction and local search publish that same phase telemetry. With verbose logging, the engine reports the first observed construction work promptly and then emits progress at roughly one-second intervals during long-running phases; bindings and console consumers receive the same structured events.

Consume them in a loop:

use solverforge::SolverEvent;

let (job_id, mut rx) = MANAGER.solve(solution).expect("solver job should start");

while let Some(event) = rx.blocking_recv() {
    match event {
        SolverEvent::Progress { metadata } => {
            println!(
                "job {} state {:?} current {:?} best {:?}",
                metadata.job_id,
                metadata.lifecycle_state,
                metadata.current_score,
                metadata.best_score
            );
        }
        SolverEvent::BestSolution { metadata, .. } => {
            println!(
                "new best at snapshot {:?}",
                metadata.snapshot_revision
            );
        }
        SolverEvent::PauseRequested { metadata } => {
            println!("pause requested for job {}", metadata.job_id);
        }
        SolverEvent::Paused { metadata } => {
            println!(
                "job {} paused at snapshot {:?}",
                metadata.job_id,
                metadata.snapshot_revision
            );
        }
        SolverEvent::Resumed { metadata } => {
            println!("job {} resumed", metadata.job_id);
        }
        SolverEvent::Completed { metadata, .. } => {
            println!(
                "job {} completed with reason {:?}",
                metadata.job_id,
                metadata.terminal_reason
            );
            break;
        }
        SolverEvent::Cancelled { metadata } => {
            println!("job {} cancelled", metadata.job_id);
            break;
        }
        SolverEvent::Failed { metadata, error } => {
            println!("job {} failed: {}", metadata.job_id, error);
            break;
        }
    }
}

The event variants are:

  • Progress — telemetry plus lifecycle metadata
  • BestSolution — an owned structurally complete improving solution plus a retained snapshot
  • PauseRequested — pause has been requested but not yet settled
  • Paused — the runtime reached a safe resumable boundary; snapshot metadata is present only after mandatory construction is complete
  • Resumed — a paused job continued from its exact retained in-process state
  • Completed — the final owned structurally complete best solution
  • Cancelled — the job was explicitly cancelled
  • Failed — the runtime aborted with an error

Candidate Trace Diagnostics

Set [candidate_trace].max_entries in solver.toml to retain a bounded ordered prefix of candidates pulled by the engine. A trace records the canonical config, resolved phase plan and execution policy, source/phase/step coordinates, logical operation identity, and ordered disposition changes such as interruption, evaluation, rejection, selection, and application. Truncation, incomplete identity, and provenance status are explicit; a digest alone is not proof of a complete or qualified run.

Routine SolverEvent metadata, get_status(...), and snapshots intentionally leave telemetry.candidate_trace empty. Candidate traces can be much larger than aggregate counters, so request one atomically with get_telemetry_detail(job_id). The returned SolverTelemetryDetail pairs its optional trace with a SolverStatus from the same publication instant, including the exact score and latest snapshot revision. This prevents a caller from combining a trace from one progress publication with counters from another.

Advanced benchmark or bridge callers can submit externally validated trace provenance through solve_with_qualified_candidate_trace_provenance(...). Qualification changes only the diagnostic header; it does not select a different runner or lifecycle.

Solver Status

Check the current state of a job:

let status = MANAGER.get_status(job_id).expect("job should exist");

println!("state: {:?}", status.lifecycle_state);
println!("terminal reason: {:?}", status.terminal_reason);
println!("checkpoint available: {}", status.checkpoint_available);
println!("event sequence: {}", status.event_sequence);
println!("latest snapshot: {:?}", status.latest_snapshot_revision);

SolverStatus is a struct, not a two-state enum. The lifecycle state is one of:

  • Solving
  • PauseRequested
  • Paused
  • Completed
  • Cancelled
  • Failed

Terminal jobs also expose a separate terminal_reason:

  • Completed
  • TerminatedByConfig
  • Cancelled
  • Failed

This distinction matters because a structurally complete job can be Completed for a normal solve end or for a configured termination condition. If a configured limit fires before mandatory construction completes, the job is Failed instead and exposes no partial solution.

Pause, Resume, and Cancel

Use lifecycle controls when you need interactive job management:

MANAGER.pause(job_id).expect("pause should be accepted");

// resume() is accepted once the job has settled in Paused.
while let Some(event) = rx.blocking_recv() {
    if let SolverEvent::Paused { metadata } = event {
        println!("paused at snapshot {:?}", metadata.snapshot_revision);
        break;
    }
}
MANAGER.resume(job_id).expect("resume should be accepted");

pause() is not a best-effort hint. The runtime settles it at a safe boundary, emits Paused, and only then allows resume(). Until then the job is PauseRequested, and resume() returns SolverManagerError::InvalidStateTransition { action: "resume", state: PauseRequested, .. }. Once mandatory construction is complete, the pause also retains a checkpoint-backed solution snapshot. Before that gate opens, the in-process state remains resumable but the Paused event has no snapshot_revision, checkpoint_available remains false, and get_snapshot(...) returns no partial construction state.

The built-in construction, local-search, and retained phase flow poll control state inside large candidate work and again around phase and terminal hooks. Pending pause or cancellation cannot be overwritten by ordinary default completion. Long-running work emits metadata-only phase pulses, paused time is excluded from active phase elapsed telemetry, and pause(), cancel(), or config termination unwind without app-side watchdog code.

Resume and cancellation clear the pause request and notify the condition variable while holding the worker’s pause gate. Resume continues the retained in-process job with its working solution, phase position, step count, and remaining budget; it does not restart from the best solution. It is not move-for-move exact: in reproducible mode, a job paused inside a local-search step and then resumed can finish with a different solution and score than the same job run without a pause.

Cancel a solving or paused job from code:

MANAGER.cancel(job_id).expect("cancel should be accepted");

Its event stream then ends with Cancelled.

Problem Changes

A Solving, PauseRequested, or Paused job accepts real-time problem changes. The runtime applies queued changes in submission order at its next safe point, starting from the best solution, rescores from scratch, and restarts its phases so construction assigns new work before local search resumes. A paused job applies its queue after resume().

use solverforge::ClosureProblemChange;

let change = MANAGER
    .add_problem_change(
        job_id,
        ClosureProblemChange::<Schedule, _>::new("raise shift 0 weight", |director| {
            director.working_solution_mut().shifts[0].weight += 5;
        }),
    )
    .expect("problem change should be queued");

The change is applied once an event’s telemetry.problem_changes_applied is at least change.get(); the next BestSolution carries the changed problem and may score lower than earlier ones. Implement ProblemChange<S> for reusable changes. A cancelled or terminal job rejects new changes with InvalidStateTransition.

Snapshots and Analysis

Every retained solution snapshot has a monotonic snapshot_revision within its job. Fetch the latest or a specific revision:

let latest = MANAGER.get_snapshot(job_id, None).expect("latest snapshot");
let exact = MANAGER
    .get_snapshot(job_id, Some(latest.snapshot_revision))
    .expect("requested snapshot");

Snapshot availability begins only after mandatory construction is complete. Callers must treat snapshot_revision as optional on lifecycle metadata and must not assume that a paused job already has a renderable solution.

If your planning solution is Analyzable, you can request score analysis for a specific snapshot revision:

let analysis = MANAGER
    .analyze_snapshot(job_id, Some(latest.snapshot_revision))
    .expect("snapshot analysis");

println!("analysis score: {:?}", analysis.analysis.score);

Analysis is snapshot-bound. You do not analyze the live mutable job directly.

After pause() is accepted, PauseRequested is published before any later worker-side event already carrying PauseRequested state. Treat that ordering as authoritative when synchronizing UI or service-layer state.

Delete and Slot Reuse

Jobs remain retained after Completed, Cancelled, or Failed so you can read their final status, snapshots, and analysis. Delete the terminal job when you are done with it:

MANAGER.delete(job_id).expect("delete terminal job");

Deleting a retained terminal job is what frees the slot for reuse.

Active Jobs

Check how many jobs the manager currently retains:

let count = MANAGER.active_job_count();

This counts visible retained jobs, including paused and terminal jobs that have not been deleted yet.

Detached Solves and Failure Isolation

Solving Without a Manager

#[planning_solution(constraints = "...")] also generates solve_with_config, which solves on the calling thread with exactly the given config and returns the best solution or a RuntimeBuildError. Neither the embedded solver_toml nor the config callback is applied:

let config = SolverConfig::from_toml_str(r#"
    random_seed = 7

    [termination]
    seconds_spent_limit = 1
"#)
.expect("config should parse");

let solved = problem.solve_with_config(config).expect("solve should finish");
println!("best score: {:?}", solved.score);

Detached solves reuse one lifecycle slot per solution type per thread, reset for each solve; telemetry, snapshots, terminal status, and pause flags do not carry into the next run.

A retained worker panic becomes a Failed job with its error and any already retained structurally complete snapshot. Other jobs keep independent lifecycle state. Delete the failed job to release its slot; analysis availability is not evidence of successful completion.

The Solvable Trait

Your planning solution must implement Solvable to be used with SolverManager. This is generated automatically when #[planning_solution] includes a constraints = "..." path.

See Also