Repository navigation
feat(solver): parallel local-search move evaluation - #78
blackopsrepl wants to merge 34 commits into
Conversation
Parallel local-search evaluation needs independent score directors that start from the committed working solution. Director gains a defaulted fork_replica capability, and ScoreDirector implements it when it carries a constraint-set constructor (fn(&S) -> C): the replica clones the working solution, builds its own constraint set for that clone, and initializes it, so incremental state is never cloned or shared. A constructor that receives the solution serves static models and dynamic host-language models alike, since a binding can rebuild its constraint set from the runtime model the solution carries. Directors without a constructor keep returning None.
… entry The configured solve entry and the bridge's dynamic runner now take a constraint-set constructor, fn(&S) -> C, in place of a ready-made constraint set. The runtime builds the working solution's constraint set with it and records it on the score director, so every solve can fork evaluator replicas whose constraint sets are built the same way as the main one. Generated solves wrap the model's zero-argument constraints function; bindings rebuild their host constraint set from the solution they receive, which is what lets dynamic models take part in parallel evaluation. The bridge documents that its score-family scope is per thread, so binding constraint sets that build DynamicScore values must enter their family themselves when replicas score on worker threads.
…ting evaluate_candidate now composes two pieces: run_trial, which performs the pinned/doable check and the apply-score-undo-restore transaction against a director and nothing else, and account_trial, which records statistics and applies the hard- and score-improvement gates on the thread that owns the step scope. Parallel evaluation needs exactly this boundary: trials run on evaluator replicas while all accounting stays on the coordinator in candidate order. Evaluated-move timing is now passed in as a Duration so the accounting side no longer needs the trial's start instant. Sequential behaviour is unchanged.
…mption The step's candidate loop is now three explicit stages: record_generated_candidate records a pull (trace, generation telemetry, progress poll) and returns a WindowSlot, run_trial evaluates it, and consume_candidate feeds the outcome through accounting, the acceptor and the forager and releases what the step no longer needs. Parallel evaluation produces several slots before consuming them in order, so these stages must be callable separately. Decisions, checks and their order are unchanged. The evaluated-move duration recorded for a scored candidate now covers the trial itself rather than also including the acceptor call, which is the quantity a trial run on another thread can report.
…e traces Parallel evaluation evaluates a window of candidates before the coordinator consumes them in order, so a step can end with evaluated candidates that the sequential loop would never have reached. That work must be visible without distorting the logical counters that drive fixed-work limits. SolverStats and SolverTelemetry gain moves_speculative, recorded through PhaseScope::record_move_speculative, and the candidate trace gains the terminal DiscardedSpeculative disposition. Sequential evaluation never produces either, so existing telemetry and traces are unchanged.
…search Local search can now evaluate a step's candidates on several cores without changing the search. Each pass produces a window of candidates serially on the main director in canonical order, runs their trials on the main director plus persistent evaluator replicas inside one rayon::scope, and consumes the results in candidate order through the unchanged accounting, acceptor and forager. Before consuming each later slot the coordinator repeats the forager quit check and the interruption check the sequential loop makes, so with a window of one the loop is exactly the sequential one. Replicas are forked from the working solution and resynchronised whenever its new working_solution_generation counter shows a write outside the loop's own commits; the counter never resets, unlike solution_revision. The selected move is applied to every replica by borrow before the owned commit, and each replica must reach the committed step score. Windows hold 32 candidates regardless of the evaluator count and never exceed the remaining in-phase move or score-calculation budget, so fixed-work runs are reproducible for every count of two or more and logical counters match sequential evaluation; discarded work is reported as speculative. AGENTS.md records the replica copies as the same clone class as partition copies and the one-scope-per-window threading rule. The runtime still passes one evaluator; move_thread_count is wired next.
SolverConfig::move_thread_count was parsed but only documented, so there was no way to ask for parallel evaluation. The compiled runtime now resolves it into the evaluator count of every acceptor-forager local-search phase: an explicit count of two or more runs that many evaluators, capped by the threads of the Rayon pool the solve runs on; None, zero and one stay sequential. Auto also stays sequential for now, so default behaviour and memory use do not change until benchmarks decide its policy. A config test pins the documented TOML forms.
… runtime Pins the end-to-end contract through SolverManager and macro-generated solving: with move_thread_count 1, 2 and 4, a fixed-work solve (seeded construction plus 150 local-search steps) reaches the same assignment, score, step count, evaluated-move count and applied-move count. The plain-scalar default selectors keep no cross-step stream state, so sequential and parallel runs must match exactly. A second test pins that Auto and an explicit count of one stay sequential and report no speculative work. Speculation under parallel evaluation is asserted in the solver's unit tests instead, because an explicit count is capped by the runner's thread count.
README's performance section now says how to turn on parallel local-search evaluation for Rust and Python models, that results are reproducible at fixed work, and that the auto default stays sequential for now. The architecture map adds the window evaluation on replicas to the search-phase box, regenerated from its SVG source.
0c1ec9f to
e7a296f
Compare
The candidate loop allocated its window-slot and trial-result vectors afresh on every step, a new per-step heap allocation on the sequential path too. The evaluator pool now owns both buffers and lends them to each step, so steps reuse their capacity.
Replica forking used to add a constructor field to every ScoreDirector and copy the solution descriptor into each replica. The field alone slowed the list-swap selector gate by about 5% in cycles at identical instruction counts, and the descriptor copy was avoidable. ScoreDirector now takes how it holds its descriptor as a type parameter, defaulting to an owned SolutionDescriptor, so every existing director keeps its exact layout. The solve entry points build the main director with a ReplicaDescriptor: a borrow of the solve's descriptor plus the constraint-set constructor. Replicas copy that handle and share the descriptor; only the working solution is cloned. This also removes the descriptor copy the solve entry used to make for its main director.
The acceptor reads a candidate's tabu signature by reference, yet every evaluated candidate cloned the move's prebuilt signature first. Prebuilt signatures are now lent directly; only moves that build one on demand produce an owned value, for that decision alone.
Benchmark: 1 vs 4 evaluatorsSetup. The series head is Employee scheduling (INRC-II)
Job shop (Taillard)
CVRP (X)
What this shows
Consequences for the open decisions
|
…ption A fixed 32-candidate window wastes most of each window when steps end early. On CVRP, where the configured forager stops after four accepted moves (about 23 evaluations per step), 40-45% of all trials were discarded and consumed evaluations fell 3-6% against one evaluator. A step's first window now matches the number of candidates the previous step consumed, at least one per evaluator, and each further window in the same step doubles up to 32. Candidates are still consumed in canonical order, so selectors without cross-step stream state keep the sequential trajectory at every evaluator count; ruin selectors and lazily activated providers stay reproducible for a given count.
Doubling windows within a step still overshot short steps: on CVRP, where a step consumes about 23 candidates, 55-67% of evaluations on four physical cores were speculative. Windows now cover the number of candidates the previous step consumed, in windows of at most 32, and then continue one candidate per evaluator. A step that ends near its predecessor's length wastes at most one window of evaluators; long steps keep full 32-candidate windows until they reach the target.
Benchmark: 1 vs 4 evaluators, 4 physical cores, 3 repeatsThis supersedes the earlier benchmark comment. Those runs used Setup
How to read the tables: the three Δ values are per repeat, with the median in bold. Cost is the benchmark's objective; lower is better. "Speculative" is the share of 4-evaluator trials that were discarded. Employee scheduling (INRC-II)
Job shop (Taillard)
CVRP (X)
What this shows
Decisions
|
Testing tag:
|
One evaluator now runs its own monomorphized step loop, the pre-parallel candidate loop unchanged except that tabu signatures are lent rather than cloned. Two or more evaluators run the parallel window loop. The phase picks the loop once, before its first step, through an internal StepEvaluator implemented by SequentialEvaluator and EvaluatorPool, so the single-evaluator path carries no pool, window, or replica state. The fused evaluate_candidate is restored for the sequential loop and VND; run_trial and account_trial remain for parallel windows, which time trials on the evaluator that ran them.
Testing tag:
|
`Move::tabu_signature` returns `Cow<'_, MoveTabuSignature>` and `borrowed_tabu_signature` is gone. Composite moves lend their prebuilt signature as `Cow::Borrowed`; leaves return `Cow::Owned`. Every wrapper already forwards `tabu_signature`, so a lent signature now survives `&M`, `MoveCandidateRef`, and the compiled runtime's `RuntimeNeighborhoodMove` instead of being rebuilt by a wrapper that never forwarded the borrowing method. Custom `Move` implementations wrap their signature in `Cow::Owned(..)`.
…eiling An evaluator pool asked for 64 evaluators forks 63 replicas although a window holds at most 32 candidates; the pin expects 31.
A window holds at most MAX_PARALLEL_EVALUATION_WINDOW candidates, so evaluators beyond it never received a slot while still costing a solution copy and a committed-move replay every step. The pool now owns both limits and clamps its evaluator count to the window ceiling.
…rnal reuse A four-evaluator window over eight candidates makes 11 heap allocations: one boxed job per spawned replica and one scratch Vec per trial in the candidate undo adapter. The compiled runtime carrier also drops retired K-opt journals instead of returning them to the pool.
Window dispatch spawned one boxed rayon job per replica, and every trial built a one-slot scratch Vec to adapt candidate-shaped undo values to the move's own journal pool. Windows now fork-join through rayon::join and a parallel iterator, whose jobs live on the stack; trial journal pools hold the moves' own undo values, which a borrowed candidate lends directly; and the window's borrowed candidates sit in inline storage sized by the window ceiling.
… carrier RuntimeNeighborhoodMove did not forward the scratch-journal methods, so every compiled-runtime K-opt trial dropped its retired journal and built a fresh one. The carrier now takes a list K-opt shell from its own pool and lends it to RuntimeListMove, which records into it and hands back an empty shell after undo; no layer builds a scratch pool of the inner undo type.
… decimal parse DynamicScore equality compares the declared family while ordering does not, so equal-ordered scores compare unequal; zero depends on the calling thread's family scope; and an integer part parsed under a decimal family stays unscaled, so parse does not invert Display.
DynamicScore carried its declared family in the value: equality compared it while ordering did not, and zero() and from_level_numbers() read a thread-local family, so a replica scoring on a worker thread outside the solve's scope produced scores that compared unequal to the solve thread's. The family now belongs to the solve only. Values hold levels; equality, ordering, and hashing agree; and scoring reads no thread state. Display and parse read the active family, and parse scales whole-number parts under HardSoftDecimal so it inverts Display. `active_dynamic_score_family` exposes the scope to bindings. Removed: the `family` field, `with_family`, `hard_soft_decimal`, `hard_medium_soft` (use `of`), and `zero_for_family` (use `ZERO`).
…sequential trajectory A three-route list model with inverse, index, previous, and next shadows runs at 1, 2, and 4 move threads. Each step selects the same move, the consumed candidate-trace pulls match the sequential run in order once speculative pulls are set aside, and the final routes, score, and shadows agree.
…shadows The list fixture's visit terms now read only the inverse, index, and previous shadows. The incremental director never re-scores the visits whose shadows a list move rewrites: after construction routes every visit, the score still counts all 18 as unrouted, and evaluator replicas, which initialize from a fresh clone, disagree with the sequential trajectory.
PlanningSolution gains three defaulted hooks: list_shadow_elements names the elements an owner's shadow update writes and the change source their stream rows use, clear_list_shadows clears an element no list holds, and list_shadow_source tells a collection's stream source which change source its rows react to.
…evaluation A route plan whose stop rows read their inverse and index shadows must score like a fresh evaluation after a stop moves between routes in either notification order, leaves and rejoins every route, or shifts within a reversed route. The director never re-scores the stops whose shadows an owner update rewrites.
An owner's list shadow update rewrites fields of the elements it holds, but the director notified only the owner, so streams over those elements kept the values they were initialized with. A model reading inverse, index, or previous shadows scored every visit unrouted for the whole solve, and evaluator replicas, initialized from a fresh clone, diverged from it. The director now retracts held element rows before an owner changes, retracts arrivals, rewrites the shadows, and inserts every element the owner holds; elements left in no list are cleared and re-inserted. Generated support reports the elements and clears shadows, fact and list-element stream sources react to the element change source, and update_all_shadows clears every configured element shadow before rebuilding.
…luation At 1, 2, and 4 move threads the consumed candidate pulls, with their identities and decisions, match the sequential run in order once speculative pulls are set aside.
…lock rule The config wireframe and MoveThreadCount comments described Auto as auto-detection while it stays sequential, and omitted the 32-evaluator window ceiling; the README now states both bounds. The bridge wireframe records that bindings must release a host-runtime lock for the solve, and the solver wireframe maps the split parallel tests and the allocation counter.
Every counter was accumulated twice: once in the running phase's PhaseStats and again in SolverStats. Nothing tied the copies together, they had already drifted (moves_speculative existed only at the solve level), and each phase's record was dropped when its scope ended, so completed telemetry could not separate construction from local search. SolverStats now owns one PhaseStats record per phase. PhaseScope opens its record on creation and closes it on drop; every event is recorded once, into the running phase, or into solve-level work between phases. Run counters are methods summing the finished phases, solve-level work, and the running phase, so they always agree with the per-phase records. SolverStats also owns the single pause-aware solve clock that SolverScope and PhaseScope read, replacing their separate start and pause instants. BREAKING CHANGE: SolverTelemetry.phase is replaced by phases, one PhaseTelemetry per phase started so far in execution order; PhaseTelemetry gains moves_speculative; SolverStats run counters are methods instead of public fields.
…s method The selector cursor gate lives outside the workspace targets, so the SolverStats counter-method change in 944d867 did not reach it.
944d867 retained each finished phase as PhaseTelemetry, allocating its phase-type String and the list's buffer once per solve. Finished phases now move into an inline SmallVec as PhaseStats and become PhaseTelemetry only when a snapshot asks for them. Phase progress also built a full telemetry snapshot for the no-op progress callback, which discards it; it now returns early unless the callback publishes progress.
report_progress and report_best_solution built full telemetry snapshots that the unit progress callback discards, now including one PhaseTelemetry per phase. Both return early unless the callback publishes progress, as phase progress already does.
Refs #76
Goal
Evaluate local-search candidates on several cores within one solve, for static Rust models and dynamic host-language models alike, without changing the search algorithm:
Rayon is the only executor: one
rayon::scopeper evaluation window, on the solve thread's current pool.Non-goals
Design
Evaluator replicas
Director::fork_replica(&self) -> Option<Self>builds an independent replica: a clone of the working solution plus a constraint set built for that clone and initialized against it.ScoreDirectorcan fork when it carries a constraint-set constructor, set withwith_replica_constraints(fn(&S) -> C).Every solve entry point takes that constructor in place of a ready-made constraint set. It builds the main director's constraint set and every replica's with it, so main and replica sets are built the same way and every model can fork:
|_| define_constraints(), wrapping the model's zero-argument constraints function;solverforge_bridge::try_run_dynamic_solver_with_config_partstakes the samefn(&S) -> C. Bindings pass a function that rebuilds their host constraint set from the solution it receives; in solverforge-py that isPyDynamicConstraintSet::from_solution.When more than one evaluator is configured, a director that cannot fork is a wiring error and panics. There is no sequential fallback.
Replica initialization clones the working solution: once per replica at local-search phase start, and again whenever something other than the loop's own commits writes the working solution (construction before the phase, problem changes, the partitioned-search merge).
ProblemChange::applyonce per replica.This is the same class of clone as partitioned search's partition copies. It is cold — never per candidate, never per step.
Keeping replicas in sync
SolverScopegains a monotonicworking_solution_generation. It increments on every committed mutation and on every wholesale replacement, and it never resets; the existingsolution_revisiondoes reset on replacement. The evaluator pool records the generation it is synchronized to:&MoveCandidateRef(do_moveborrows). The replica's score must equal the committed step score; a mismatch panics with a divergence message, which the runtime reports through the existingFailedlifecycle. The pool then records the new generation.Dynamic models
Dynamic models run through the same compiled runtime and the same window loop. Parallel evaluation asks four things of a binding:
scoped_dynamic_score_familyis thread-local, and only the solve thread enters it. A constraint set that buildsDynamicScorevalues must therefore enter its declared family inside its ownConstraintSetmethods; otherwise worker-computed scores do not match the solve thread's.Host candidate providers run during production on the coordinator, exactly as today. A host exception raised inside a trial leaves
rayon::scopewith its original panic payload, so host tracebacks survive.The step
Each step alternates between producing, evaluating and consuming a window of candidates:
capacitycandidates serially on the main director, in canonical order. All existing control polls, trace pulls and generation telemetry run here exactly as today. Cartesian previews mutate and restore only the main director.MoveCandidateReffrom the cursor (shared borrows only). The main director and every replica pull slot indexes from oneAtomicUsizeand run the same trial kernel on their own director; the main director's share runs inline on the coordinator thread.i > 0, the coordinator re-applies the forager quit check and the interruption check that the sequential loop would have made at that point. Any slot not consumed is discarded as speculative work.capacityis1when no replicas exist. Otherwise it ismin(PARALLEL_EVALUATION_WINDOW, remaining in-phase move/score-calculation budget), withPARALLEL_EVALUATION_WINDOW = 32. The cursor never needs to beSync: the coordinator borrows the window's candidates, and workers receive only that shared slice.Determinism and accounting contract
move_thread_countresolving to1runs the current sequential loop: same candidates, same checks in the same order, same telemetry counts.moves_evaluated,score_calculations, step, move-count and score-calculation limits) count only consumed candidates. Fixed-work limits therefore trigger at the same point as in sequential evaluation.moves_generatedcounts every pull.SolverTelemetry::moves_speculativecounts candidates that were evaluated ahead of the decision point and then discarded.CandidateTraceDisposition::DiscardedSpeculative.Configuration
SolverConfig::move_thread_countis already part of the configuration surface. Today only partitioned search reads it.move_thread_countNoneAuto(default)Count(0),Count(1)Count(n ≥ 2)min(n, rayon::current_num_threads())evaluators: the main director plus that many minus one replicasTOML:
move_thread_count = { count = 4 }. Python configs pass the same key through:{"move_thread_count": {"count": 4}}.Rules for implementers
AGENTS.mdand load the session KV:nisaba --quiet docs/agents/drift-guard.nisaba scan drift-guard/. Mirror this checklist into your task ledger before the first code change.unsafe, nounsafe impl Send/Sync, noArc/Rc, no newdyn, nostd::thread::spawnorstd::thread::sleep, no.clone()of moves, candidates or scoring state. The only new clone is replica initialization inScoreDirector::fork_replica_impl.PhantomData<fn() -> T>.mod.rsfiles contain declarations and re-exports only. New files stay under 500 lines. Several files touched here are already over that size (run.rs,scope_core.rs,scope_progress.rs,stats/solver.rs,stats/candidate_trace.rs): add only the lines listed for them, and put new logic in new files.cargo check -p <crate> --all-targets --message-format short, the listed tests,cargo clippy -p <crate> --all-targets -- -D warnings, andcargo fmt --all -- --check.MoveCandidateRefturns out not to beSync, or a struct literal needs a field the plan does not mention.solverforge-bench, do not bump versions, and do not hand-editCHANGELOG.md. solverforge-py changes belong to their own pull request (see Downstream).Commit plan
C1 —
feat(scoring): fork evaluator replicas from ScoreDirectorcrates/solverforge-scoring/src/director/traits.rs. Add a defaulted method toDirector<S>, afterreset. Thewhere Self: Sizedclause keeps the trait object-safe for the existing&dyn Director<S>boundaries.crates/solverforge-scoring/src/director/score_director/incremental.rs:replica_constraints: Option<fn(&S) -> C>toScoreDirector, afterentity_counter, and initialize it toNoneinwith_descriptor. Every other constructor delegates towith_descriptor; confirm that.impl<S, C> ScoreDirector<S, C>block that defineswith_descriptor, add:Debugimpl lives inadapters.rs; show the new field there asreplica_constraints.is_some().crates/solverforge-scoring/src/director/score_director/adapters.rs. Inimpl<S, C> Director<S> for ScoreDirector<S, C>, addfn fork_replica(&self) -> Option<Self> { self.fork_replica_impl() }.crates/solverforge-scoring/src/director/tests/score_director.rs(add at most 90 lines). Add a helperfn unassigned_constraints(_: &TestSolution) -> (impl IncrementalConstraint<TestSolution, SoftScore>,) { (make_unassigned_constraint(),) }and three tests:fork_replica_requires_replica_constraints: a director built withoutwith_replica_constraintsreturnsNone.fork_replica_matches_score_and_stays_independent:ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)).with_replica_constraints(unassigned_constraints)and callcalculate_score().get_score()equals the main director's.before_variable_changed, a direct write, thenafter_variable_changed.fork_of_uninitialized_director_initializes_the_replica: fork before anycalculate_score(), then assert the replica is initialized and its score is correct.crates/solverforge-scoring/WIREFRAME.md. DocumentDirector::fork_replicain the trait method list andwith_replica_constraintsin theScoreDirectorpublic methods (around line 507).cargo test -p solverforge-scoring fork_replica,cargo test -p solverforge-scoring, then clippy and fmt.C2 —
feat(solver): build constraint sets from a constructor at every solve entrycrates/solverforge-solver/src/run.rs:try_run_solver_with_config_and_search,try_run_solver_with_config_and_search_requestandtry_run_solver_with_candidate_trace_request, change the parameterconstraints: Ctoconstraints: fn(&S) -> Cand forward it along the chain.try_run_solver_with_candidate_trace_request, keeplog_scale(&solution);first and replace the director construction with:crates/solverforge-macros/src/planning_solution/runtime/solve.rs. Insolve_expr, replace the argument#constraints_fn(),with(|_: &Self| #constraints_fn()) as fn(&Self) -> _,.crates/solverforge-bridge/src/runner.rs. Intry_run_dynamic_solver_with_config_parts, changeconstraints: Ctoconstraints: fn(&S) -> Cand forward it. Add to its doc comment: "constraintsbuilds a constraint set for a given solution. The runtime calls it for the working solution and for every evaluator replica, somove_thread_countevaluates dynamic models in parallel."crates/solverforge-bridge/src/score.rs. Extend the doc comment ofscoped_dynamic_score_family: "The family is per thread. The solve thread enters it for the whole solve, but parallel evaluation scores evaluator replicas on Rayon worker threads, so a constraint set that buildsDynamicScorevalues must also enter its declared family inside its ownConstraintSetmethods."crates/solverforge-solver/src/run_tests.rs(around line 168): addfn test_constraints(_: &TestSolution) -> ScoreFromSolutionConstraints { ScoreFromSolutionConstraints }and passtest_constraints.crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs(around line 565): addfn zero_variable_constraints(_: &ZeroVariableLifecycleSolution) {}and passzero_variable_constraintsin place of().crates/solverforge-macros/src/planning_solution_tests.rs. Extend the existing expansion test that checks fortry_run_solver_with_config_and_searchso it also asserts the constructor cast. Print the expansion once to getquote!'s exact spacing (for examplefn (& Self) -> _).crates/*/WIREFRAME.mdfortry_run_solver_with_config_and_searchandtry_run_dynamic_solver_with_config_parts, and update each signature to the constructor parameter.crates/solverforge-macros/WIREFRAME.md: thesolve_internalbullet (near line 257) says the constraints function is passed as the constraint-set constructor.crates/solverforge-bridge/WIREFRAME.md: the runner's constructor parameter, and the per-thread rule forscoped_dynamic_score_family.cargo check --workspace --all-targets --message-format short,cargo test -p solverforge-macros,cargo test -p solverforge --tests(the facade fixtures exercise the generated code),cargo test -p solverforge-bridge,cargo test -p solverforge-solver.C3 —
feat(solver): track working-solution generationsLanded inside C7: the counter has no reader until the evaluator pool exists, and the lint gate rejects dead code.
crates/solverforge-solver/src/scope/solver/scope_core.rs:working_solution_generation: u64toSolverScope, aftersolution_revision. Initialize it to0in every constructor; the compiler lists the struct literals.self.working_solution_generation += 1;as the first statement ofreplace_working_solution_and_reinitialize.pub(crate) fn working_solution_generation(&self) -> u64.crates/solverforge-solver/src/scope/solver/scope_progress.rs. Incommitted_mutation, addself.working_solution_generation += 1;directly afterself.advance_solution_revision();.crates/solverforge-solver/src/scope/tests.rs(add at most 40 lines). Addworking_solution_generation_is_monotonic_across_commits_and_replacement:mutate(|_| ())twice and assert the generation advanced by 2.replace_working_solution_and_reinitialize(...)and assert it advanced by 1 more.solution_revisionwent back to 1.cargo test -p solverforge-solver scope::.C4 —
refactor(solver): split the local-search trial kernel from its accountingPure refactor; every existing test must pass unchanged.
crates/solverforge-solver/src/phase/localsearch/evaluation.rs:run_trial. Its body is the director-only part of today'sevaluate_candidate, in the same order: the pinned/doable check returningNotDoable,snapshot_score_state,do_move_with_scratch,calculate_score,undo_move_with_scratch,restore_score_state, thenScored(score). It must not touch any statistics.pub(crate) fn account_trial(mov, step_scope, reference_score, selector_index, elapsed: Duration, outcome: TrialOutcome<S::Score>) -> CandidateEvaluation<S::Score>. Its body is all of today's bookkeeping, in today's order:NotDoable: evaluated-move record, unscored label, not-doable counters.Scored:record_score_calculation, the hard-delta counters, then the hard-improvement and score-improvement rejections.evaluate_candidatekeeps its signature and becomesaccount_trial(mov, step_scope, reference_score, selector_index, evaluation_started.elapsed(), run_trial(mov, step_scope.score_director_mut(), journal_pool)). Bind the outcome first soelapsed()is taken after the trial.record_evaluated_move(step_scope, selector_index, elapsed: Duration)to take aDuration. Update its callers:phase/localsearch/phase/candidates.rs(around line 220) andphase/localsearch/vnd/phase.rs(around line 341) both passevaluation_started.elapsed().cargo test -p solverforge-solver localsearch, then the fullcargo test -p solverforge-solver.C5 —
refactor(solver): extract local-search candidate generation and consumptionPure refactor of
crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs; behaviour is byte-identical.crates/solverforge-solver/src/phase/localsearch/phase/decision.rsand declaremod decision;next tomod candidates;inphase/localsearch/phase.rs. It contains:pub(super) fn record_generated_candidate(cursor: &C, candidate_id, generation_started: Instant, step_scope, state: &mut CandidateLoopState) -> WindowSlot. This is today's block from the selector-index lookup through the progress poll (lines 102–137): selector index, move and selector labels,record_candidate_pull,generated_moves += 1, generated-move telemetry with the generation elapsed time, and the progress poll.pub(super) fn consume_candidate(cursor: &mut C, step_scope, slot: &WindowSlot, outcome: TrialOutcome<S::Score>, elapsed: Duration, last_step_score, acceptor, forager, requires_move_signatures, state: &mut CandidateLoopState). This is today's block fromstate.evaluated_moves += 1to the end of the loop body (lines 156–303), withevaluate_candidatereplaced byaccount_trial(&mov, …, elapsed, outcome)and the post-acceptorrecord_evaluated_movegivenelapsed. Re-borrowmovfromcursor.candidate(slot.id), and keep its last use before anycursor.release_candidatecall, as today.candidates.rs, the loop body becomes:record_generated_candidate;run_trialtimed withInstant::now();consume_candidate.cargo test -p solverforge-solver localsearch; every existing foraging, streaming and selector test passes with no expectation changed.C6 —
feat(solver): report speculative evaluation in telemetry and candidate tracescrates/solverforge-solver/src/stats/solver.rs. Add amoves_speculative: u64field toSolverStats, declared likemoves_evaluated, withpub fn record_move_speculative(&mut self), and copy it in the telemetry snapshot (around line 279).crates/solverforge-solver/src/stats/telemetry.rs. Addpub moves_speculative: u64toSolverTelemetry, documented as "Candidates evaluated ahead of the sequential decision point by parallel evaluation and then discarded. Zero for sequential evaluation." Initialize it to0innew_const.crates/solverforge-solver/src/scope/solver/scope_progress.rs. Addpub(crate) fn record_move_speculative(&mut self)next torecord_score_calculation.crates/solverforge-solver/src/scope/phase.rsforwards it throughPhaseScope, mirroringrecord_score_calculationaround line 486.crates/solverforge-solver/src/stats/candidate_trace.rs. Add aDiscardedSpeculativevariant documented as "Parallel evaluation pulled this candidate ahead of the sequential decision point; the step ended before the candidate was consumed." Give it code10incode()and list it inis_terminal().manager/mod_tests_integration/resume_support.rsandmanager/mod_tests_integration/runtime_helpers.rs.crates/solverforge-solver/src/stats/tests.rs. Addspeculative_moves_reach_the_telemetry_snapshot.crates/solverforge-solver/WIREFRAME.md. Add theSolverTelemetryfield and theCandidateTraceDispositionvariant.cargo test -p solverforge-solver stats::,cargo check --workspace --all-targets(the console crate must still build).C7 —
feat(solver): parallel evaluation windows for acceptor-forager local searchNew module
crates/solverforge-solver/src/phase/localsearch/parallel/:mod.rs— declarations and re-exports only:budget.rs:inphase_move_count_limit - stats().moves_evaluatedandinphase_score_calc_count_limit - stats().score_calculations, both withsaturating_sub, andNonewhen neither limit is set. Both limit fields are alreadypub.pool.rs:S: PlanningSolution, D: Director<S>, M: Move<S>. If the compiler reports an unused type parameter, add_phantom: PhantomData<fn() -> (S, M)>. Methods:new(requested: usize) -> Self: storesrequested.max(1)— the number of evaluators, main director included — with no replicas.is_parallel(&self) -> bool: true when replicas exist.window_capacity(&self, remaining: Option<u64>) -> usize:1when not parallel; otherwisePARALLEL_EVALUATION_WINDOW.min(remaining as usize).max(1), with a missing budget meaning unbounded.sync(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>):requested <= 1orsynced_generation == Some(solver_scope.working_solution_generation()).requested - 1of them withsolver_scope.score_director().fork_replica().expect("parallel evaluation requires a director built with a constraint-set constructor"), then setsynced_generation. The main director is the remaining evaluator.advance(&mut self, selected: &MoveCandidateRef<'_, S, M>, expected: S::Score): when parallel,self.replicas.par_iter_mut().for_each(|replica| { let _undo = selected.do_move(&mut replica.director); let score = replica.director.calculate_score(); assert_eq!(score, expected, "evaluator replica diverged from the committed step"); }).mark_synced(&mut self, generation: u64): when parallel, setsynced_generation = Some(generation).pub(super) fn parts_mut(&mut self) -> (&mut Vec<(usize, TrialOutcome<S::Score>, Duration)>, &mut [EvaluatorReplica<S, D, M>]): the main director's output buffer and the replicas, borrowed disjointly.#[cfg(test)] pub(super) fn replica_count(&self) -> usize.window.rs. The coordinator borrows the window's candidates from the cursor and passes the slice in, so this function needs no cursor type and the cursor never has to beSync. The main director takes its share inline on the coordinator thread; the replicas take theirs in spawned tasks.A worker panic propagates out of
rayon::scopewith its original payload, exactly like a sequential trial panic.Modified files:
phase/localsearch/mod.rs. Addmod parallel;.phase/localsearch/phase.rs:solve_local_search_with_resourcesgains the parameterevaluator_count: usizeafterstep_limit. It createslet mut evaluators = EvaluatorPool::<S, D, M>::new(evaluator_count);before the loop and passes&mut evaluatorstoexecute_step.LocalSearchPhase::solvepasses1.phase/localsearch/phase/step.rs.execute_steptakesevaluators: &mut EvaluatorPool<S, D, M>:StepScope::new:evaluators.sync(step_scope.phase_scope().solver_scope());.evaluatorstoevaluate_candidates.step_scope.apply_committed_change(...):evaluators.advance(&selected_move, selected_score);.apply_committed_changereturns:evaluators.mark_synced(step_scope.phase_scope().solver_scope().working_solution_generation());.phase/localsearch/phase/decision.rs. Add the discard helpers:interrupt_window_at(cursor, step_scope, window: &mut [WindowSlot], index, evaluated: bool):index: record theInterruptedBeforeEvaluationdisposition, exactly as the sequential loop does, and do not release it.DiscardedSpeculativeand release it.evaluatedis true, callrecord_move_speculative()for slotindexand every later slot.discard_window_from(cursor, step_scope, window, from, evaluated: bool): recordDiscardedSpeculativefor slotsfrom.., release them, and count them as speculative whenevaluatedis true.phase/localsearch/phase/candidates.rs.evaluate_candidatestakesevaluators: &mut EvaluatorPool<S, D, M>, and its loop becomes:With
capacity == 1this performs the same checks, pulls, records and decisions in the same order as today. That property is what keeps every existing local-search test valid, so do not reorder anything to make the code shorter.runtime/compiler/executor/runner/local_search.rs. Pass1for now; C8 wires the configuration.parallel/tests.rs. Build a director with real incremental scoring overcrate::test_utils::NQueensSolution:solverforge_scoring::IncrementalUniConstraintoverqueens, built withsolverforge_scoring::stream::collection_extract::{source, ChangeSource}andChangeSource::Descriptor(0). Follow the pattern insolverforge-scoring/src/director/tests/score_director.rs, and use a non-constant weight such as(row * 3 + column) % 5. Wrap it infn constraints(_: &NQueensSolution) -> (impl IncrementalConstraint<NQueensSolution, SoftScore>,).ScoreDirector::with_descriptor(solution.clone(), constraints(&solution), create_nqueens_descriptor(), |s, _| s.queens.len()).with_replica_constraints(constraints).SolverScope::new(director).with_seed(7)followed bystart_solving().SelectorCursorSource::new(ChangeMoveSelector::simple(get_queen_row, set_queen_row, 0, 0, "row", values)).Tests:
pool_with_one_evaluator_never_forks: build the director withScoreDirector::simple(no constructor), create the pool withrequested = 1, thensync; assert!is_parallel()andwindow_capacity(None) == 1.pool_requires_a_forkable_director: the same director withrequested = 2;#[should_panic(expected = "constraint-set constructor")]onsync.pool_rebuilds_replicas_when_the_generation_changes: withrequested = 4, sync and assertreplica_count() == 3; commit a change throughsolver_scope.mutate(...), sync again, then assert the replica scores equal the main director's score.sequential_and_parallel_reach_identical_trajectories: runHillClimbingAcceptorwithAcceptedCountForager::new(4, false)and step limit 25 for evaluator counts 1, 2 and 4. Assert equal queen rows,moves_evaluated,moves_appliedand step counts.speculative_evaluations_are_reported_and_not_counted: runAcceptedCountForager::new(1, false)with evaluator counts 1 and 2. Assert equalmoves_evaluated, a nonzeromoves_speculativefor count 2, and zero for count 1.in_phase_move_limit_reserves_the_window: setsolver_scope.inphase_move_count_limit = Some(5)and runBestScoreForagerwith evaluator count 2. Assertmoves_evaluated == 5andmoves_speculative == 0.replicas_follow_solution_replacement: run a phase, callreplace_working_solution_and_reinitializewith a different solution, then run a second phase with evaluator count 2. The run must complete without a divergence panic and match the same sequence run with evaluator count 1.AGENTS.md:rayon::scopeper evaluation window on the solve thread's current pool; do not add dedicated pools or blocking waits inside evaluation workers."crates/solverforge-solver/WIREFRAME.md:phase/localsearch/parallel/{mod,budget,pool,window,tests}.rsandphase/localsearch/phase/decision.rs.Verify:
cargo test -p solverforge-solver localsearch, the fullcargo test -p solverforge-solver, clippy and fmt.C8 —
feat(solver): drive acceptor-forager evaluation from move_thread_countphase/localsearch/parallel/config.rs(new; declare it inparallel/mod.rsand re-export it):runtime/compiler/executor/runner/local_search.rs. Addevaluator_count: usizetoRuntimeLocalSearch::AcceptorForager, destructure it insolve, and pass it tosolve_local_search_with_resources.runtime/compiler/executor/runner/lowering.rs. Setevaluator_count: resolve_evaluator_count(&solver_config.move_thread_count)at bothRuntimeLocalSearch::AcceptorForagerconstruction sites: the default-runtime branch near line 82 andlower_local_searchnear line 163.parallel/tests.rs. Addevaluator_count_resolution:None,Auto,Count(0)andCount(1)resolve to 1, andCount(2)resolves tomin(2, rayon::current_num_threads()).crates/solverforge-config/WIREFRAME.md. UnderMoveThreadCount(around line 713) and themove_thread_countrow (around line 63), document the consumers and the configuration table above.cargo test -p solverforge-solver runtime::,cargo test -p solverforge-solver,cargo test -p solverforge-config.C9 —
test(solverforge): parallel local-search parity through the generated runtimecrates/solverforge/tests/parallel_local_search/domain/, modelled onscalar_runtime_publication/domain/:mod.rs:solverforge::planning_model!withroot = "crates/solverforge/tests/parallel_local_search/domain"and modulesplan,resource,task.resource.rs: the same shape as the existingResource.task.rs:#[planning_id] pub id: String,pub weight: i64, and#[planning_variable(value_range_provider = "resources", allows_unassigned = true)] pub resource_idx: Option<usize>.plan.rs:#[planning_solution(constraints = "define_constraints", config = "parallel_config")]withresources,tasks,score: Option<HardSoftScore>, and a plain fieldpub move_threads: u32.(task.weight * (resource_idx as i64 + 3)) % 17for each assigned task.parallel_config(solution, _base)parses the TOML below withSolverConfig::from_toml_str, setsconfig.move_thread_counttoMoveThreadCount::Autowhenmove_threads == 0and toMoveThreadCount::Count(n)otherwise, and returns the config.crates/solverforge/tests/parallel_local_search.rs. Use 6 resources and 40 tasks with deterministic weights (for example(i * 7 + 3) % 11). Solve throughSolverManageras inscalar_runtime_publication.rs.fixed_work_trajectory_is_identical_across_thread_counts: formove_threads1, 2 and 4, collect the completed solution andmetadata.telemetry. Assert identicalresource_idxvectors, scores,step_count,moves_evaluatedandmoves_applied. The default plain-scalar selectors keep no stream state across steps, so all three runs must match.sequential_resolution_reports_no_speculation: withmove_threads0 (Auto) and 1, assertmoves_speculative == 0.moves_speculative > 0here: on a single-core runnerCount(n)clamps to 1. The unit tests in C7 cover speculation independently of the core count.cargo test -p solverforge --test parallel_local_search,cargo test -p solverforge --tests.C10 —
docs: describe parallel local-search evaluationREADME.md. In the existing configuration or performance guidance, add two or three lines:move_thread_count = { count = 4 }evaluates local-search candidates on up to four cores, for Rust models and for Python models through solverforge-py; results are reproducible at fixed work. If the README has no such section, skip this file.docs/architecture.html. Check whetherassets/solverforge-architecture.svgdepicts the local-search step. If it does, add the evaluator replicas to the SVG and regenerate the page withnode scripts/solverforge-architecture.js. Do not edit the HTML by hand. If the step is not depicted, make no change.Verification gates
Run each gate once per landed series and record its verdict on this PR.
make build,make examples,make test,make lint,make pre-release. All must pass with zero ignored tests.BASELINE=<merge-base of this branch with main> BASELINE_CURSOR_API=1 make bench-zero-regression.report.json.bdcdb644, baseline1f224d06). All 12 cases match semantically and allocation counts match exactly.list_swapmeasured +7% cycles at identical instruction and branch counts, reproduced across runs. Topdown counters put the difference in front-end bandwidth: the case's loop is inlined into the gate binary'smain, whose start moved from offset 48 to 32 within a 64-byte block because unrelated code changed size. An unused field added toScoreDirectoron the baseline alone reproduced it (+5%).RUSTFLAGS="-C llvm-args=-align-all-functions=6 -C llvm-args=-align-loops=64"), every case's wall-time and cycle median is within ±2% of baseline (list_swap+0.10% wall / +0.33% cycles; worstconstruction_full+1.06% / +1.95%;k_opt−3.8% wall). The only failing criterion is cache-misses onlist_swapandnearby_swap(+11.5%, +10.2% on small absolute counts).move_thread_count1 againstCount(4)on the scalar, list and CVRP suites using the existing benchmark infrastructure.Decisions
Auto: resolved, sequential. Parallel evaluation ships opt-in throughmove_thread_count = { count = N }. On four free cores it is faster on every problem type measured, improves quality where evaluation dominates, and never made results worse (benchmark). Default behaviour and memory use do not change.4e691752,7496f683). This took CVRP from 55–67% to 14–26% speculative trials.Testing
The tag
parallel-evaluation-test.2points at66b020b3on this branch. It is a testing tag, not a release: nothing is published to crates.io.Depend on the tag. Override every SolverForge crate in the dependency graph so they resolve to one copy:
Turn it on in
solver.toml. It applies to acceptor-forager local search; construction, VND and partitioned search stay sequential.The count is capped by the threads of the Rayon pool the solve runs on.
"auto","none"and{ count = 1 }run the unchanged sequential loop.Compare against
{ count = 1 }at the same time limit, on the same cores, with nothing else running. Report:moves_evaluatedandmoves_speculativefrom the solve'sSolverTelemetry(moves_speculativecounts trials evaluated ahead of the decision point and discarded);Fixed-work runs (step or move limits) are reproducible for a given count. With selectors that keep no stream state across steps, every count reaches the same solution.
Python models need the solverforge-py pull request, which follows the release of this series.
Downstream
solverforge-py
The Python-binding work lives in SolverForge/solverforge-py#1 and lands after the release that ships this series.
Portfolio
The cooperative portfolio suggested in #76 can build on
Director::fork_replicaandEvaluatorPoolas a separate scheduler, in its own issue.