Documentation

SolverManager

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

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 exact in-process pause/resume.

Creating a SolverManager

SolverManager::new() is a const fn that takes no arguments. It’s designed to be used as a static:

use solverforge::prelude::*;

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

Solving

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

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

The receiver yields SolverEvent<S> values, not raw (solution, score) tuples. 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.

PhaseTelemetry identifies the active phase and its local elapsed, step, move, score-calculation, generation-time, and evaluation-time counters. 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.

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");
MANAGER.resume(job_id).expect("resume should be accepted");
MANAGER.cancel(job_id).expect("cancel 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(). 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.

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 are currently running:

let count = MANAGER.active_job_count();

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

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