From 7c7b9335d91772b2204bf760c18bfc8e91051b9c Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:40:50 +0200 Subject: [PATCH 01/30] feat(scoring): fork evaluator replicas from ScoreDirector 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. --- crates/solverforge-scoring/WIREFRAME.md | 4 +- .../src/director/score_director/adapters.rs | 5 ++ .../director/score_director/incremental.rs | 35 +++++++++++ .../src/director/tests/score_director.rs | 62 +++++++++++++++++++ .../src/director/traits.rs | 12 ++++ 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/crates/solverforge-scoring/WIREFRAME.md b/crates/solverforge-scoring/WIREFRAME.md index 8ddc4476..91b39b5d 100644 --- a/crates/solverforge-scoring/WIREFRAME.md +++ b/crates/solverforge-scoring/WIREFRAME.md @@ -376,6 +376,7 @@ pub use stream::{ | `snapshot_score_state` | `fn snapshot_score_state(&self) -> DirectorScoreState` | Snapshot committed score state for speculative evaluation | | `restore_score_state` | `fn restore_score_state(&mut self, state: DirectorScoreState)` | Restore a previously snapshotted committed score state | | `reset` | `fn reset(&mut self)` | Default: no-op | +| `fork_replica` | `fn fork_replica(&self) -> Option where Self: Sized` | Independent evaluator replica with initialized incremental state; default: `None` | ### `DirectorScoreState` @@ -504,7 +505,7 @@ Stock collectors include `count()`, `sum()`, `load_balance()`, **`ScoreDirector`** where `S: PlanningSolution`, `C: ConstraintSet` - Primary incremental scoring director. Zero-erasure. -- Public methods: `new()`, `with_descriptor()`, `simple()` (convenience for +- Public methods: `new()`, `with_descriptor()`, `with_replica_constraints()`, `simple()` (convenience for `ScoreDirector`), `simple_zero()` (test helper with empty descriptor), `working_solution()`, `working_solution_mut()`, `into_working_solution()`, `calculate_score()`, `before_variable_changed()`, @@ -515,6 +516,7 @@ Stock collectors include `count()`, `sum()`, `load_balance()`, - Returns borrowed constraint metadata views from the monomorphized `ConstraintSet` on demand. - `simple(solution, descriptor, entity_counter)` — creates `ScoreDirector` with empty constraint set - `simple_zero(solution)` — creates `ScoreDirector` with empty descriptor and zero entity counter +- `with_replica_constraints(constraints: fn(&S) -> C)` — records the constraint-set constructor used by `Director::fork_replica`; a replica clones the working solution, builds its constraint set from that clone, and initializes it. Directors without a constructor do not fork. - Implements `Director` ### Constraint Types diff --git a/crates/solverforge-scoring/src/director/score_director/adapters.rs b/crates/solverforge-scoring/src/director/score_director/adapters.rs index cd39bd8c..781135e0 100644 --- a/crates/solverforge-scoring/src/director/score_director/adapters.rs +++ b/crates/solverforge-scoring/src/director/score_director/adapters.rs @@ -16,6 +16,7 @@ where .field("initialized", &self.is_initialized()) .field("cached_score", &self.get_score()) .field("constraint_count", &self.constraint_count()) + .field("forks_replicas", &self.replica_constraints.is_some()) .finish() } } @@ -91,4 +92,8 @@ where fn reset(&mut self) { self.reset_impl(); } + + fn fork_replica(&self) -> Option { + self.fork_replica_impl() + } } diff --git a/crates/solverforge-scoring/src/director/score_director/incremental.rs b/crates/solverforge-scoring/src/director/score_director/incremental.rs index 175d1757..0ee52e56 100644 --- a/crates/solverforge-scoring/src/director/score_director/incremental.rs +++ b/crates/solverforge-scoring/src/director/score_director/incremental.rs @@ -78,6 +78,11 @@ where throughout the solver pipeline. */ pub(super) entity_counter: fn(&S, usize) -> usize, + /* Constraint-set constructor used to build evaluator replicas. + + `None` means this director cannot fork replicas. + */ + pub(super) replica_constraints: Option C>, _phantom: PhantomData S>, } @@ -129,10 +134,40 @@ where initialized: false, solution_descriptor, entity_counter, + replica_constraints: None, _phantom: PhantomData, } } + /* Records the constructor that builds each evaluator replica's constraint + set. The constructor receives the replica's own working solution. + */ + pub fn with_replica_constraints(mut self, constraints: fn(&S) -> C) -> Self { + self.replica_constraints = Some(constraints); + self + } + + /* Builds an initialized evaluator replica from a clone of the working + solution and a constraint set built for that clone. + + The working-solution clone is the evaluator replica clone boundary: it + happens when replicas are created, never per candidate or per step. + */ + pub(crate) fn fork_replica_impl(&self) -> Option { + let constraints = self.replica_constraints?; + let solution = self.working_solution.clone(); + let replica_constraints = constraints(&solution); + let mut replica = Self::with_descriptor( + solution, + replica_constraints, + self.solution_descriptor.clone(), + self.entity_counter, + ); + replica.replica_constraints = Some(constraints); + replica.calculate_score_impl(); + Some(replica) + } + /* ========================================================================= Private implementation methods (shared between inherent and trait impl) ========================================================================= diff --git a/crates/solverforge-scoring/src/director/tests/score_director.rs b/crates/solverforge-scoring/src/director/tests/score_director.rs index fbec0417..0df83c5e 100644 --- a/crates/solverforge-scoring/src/director/tests/score_director.rs +++ b/crates/solverforge-scoring/src/director/tests/score_director.rs @@ -307,3 +307,65 @@ fn test_add_then_remove_value() { director.do_change(0, 0, |s| s.values[0] = None); assert_eq!(director.get_score(), SoftScore::of(-2)); } + +fn unassigned_constraints( + _solution: &TestSolution, +) -> (impl IncrementalConstraint,) { + (make_unassigned_constraint(),) +} + +#[test] +fn fork_replica_requires_replica_constraints() { + let solution = TestSolution { + values: vec![Some(1), None], + score: None, + }; + let mut director = ScoreDirector::new(solution, (make_unassigned_constraint(),)); + director.calculate_score(); + + assert!(director.fork_replica().is_none()); +} + +#[test] +fn fork_replica_matches_score_and_stays_independent() { + let solution = TestSolution { + values: vec![Some(1), None, None], + score: None, + }; + let mut director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)) + .with_replica_constraints(unassigned_constraints); + let main_score = director.calculate_score(); + + let mut replica = director + .fork_replica() + .expect("director with a constraint constructor must fork"); + assert!(replica.is_initialized()); + assert_eq!(replica.get_score(), main_score); + + replica.before_variable_changed(0, 1); + replica.working_solution_mut().values[1] = Some(7); + replica.after_variable_changed(0, 1); + + assert_eq!(replica.get_score(), SoftScore::of(-1)); + assert_eq!(director.get_score(), SoftScore::of(-2)); + assert_eq!(director.working_solution().values[1], None); +} + +#[test] +fn fork_of_uninitialized_director_initializes_the_replica() { + let solution = TestSolution { + values: vec![None, Some(3), None, None], + score: None, + }; + let director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)) + .with_replica_constraints(unassigned_constraints); + assert!(!director.is_initialized()); + + let replica = director + .fork_replica() + .expect("director with a constraint constructor must fork"); + + assert!(replica.is_initialized()); + assert_eq!(replica.get_score(), SoftScore::of(-3)); + assert!(!director.is_initialized()); +} diff --git a/crates/solverforge-scoring/src/director/traits.rs b/crates/solverforge-scoring/src/director/traits.rs index 1bc9de4b..ed55a725 100644 --- a/crates/solverforge-scoring/src/director/traits.rs +++ b/crates/solverforge-scoring/src/director/traits.rs @@ -92,4 +92,16 @@ pub trait Director: Send { // Resets the score director state. fn reset(&mut self) {} + + /* Builds an independent evaluator replica: an owned copy of the working + solution with freshly initialized incremental constraint state. + + Returns `None` when this director cannot construct one. + */ + fn fork_replica(&self) -> Option + where + Self: Sized, + { + None + } } From bd818aac0dfd08732d6476f721c9a2a90ffa1bb6 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:44:20 +0200 Subject: [PATCH 02/30] feat(solver): build constraint sets from a constructor at every solve 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. --- crates/solverforge-bridge/WIREFRAME.md | 11 ++++++++++- crates/solverforge-bridge/src/runner.rs | 6 +++++- crates/solverforge-bridge/src/score.rs | 5 +++++ crates/solverforge-macros/WIREFRAME.md | 2 +- .../src/planning_solution/runtime/solve.rs | 2 +- .../src/planning_solution_tests.rs | 3 +++ crates/solverforge-solver/WIREFRAME.md | 8 +++++--- .../mod_tests_integration/lifecycle_solutions.rs | 4 +++- crates/solverforge-solver/src/run.rs | 16 +++++++++++----- crates/solverforge-solver/src/run_tests.rs | 6 +++++- 10 files changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/solverforge-bridge/WIREFRAME.md b/crates/solverforge-bridge/WIREFRAME.md index 229095bb..30a78866 100644 --- a/crates/solverforge-bridge/WIREFRAME.md +++ b/crates/solverforge-bridge/WIREFRAME.md @@ -178,6 +178,11 @@ presentation and host-boundary conversion. `scoped_dynamic_score_family(family, callback)` sets the thread-local family used by `DynamicScore::zero()` and `DynamicScore::from_level_numbers(...)` during the callback. +The family is per thread. The solve thread enters it for the whole solve, but +parallel local-search evaluation scores evaluator replicas on Rayon worker +threads, so a binding's constraint set that builds `DynamicScore` values must +also enter its declared family inside its own `ConstraintSet` methods. + ## Public Functions ### Compiled dynamic runner entrypoints @@ -185,7 +190,7 @@ callback)` sets the thread-local family used by `DynamicScore::zero()` and ```rust pub fn try_run_dynamic_solver_with_config_parts( solution: S, - constraints: C, + constraints: fn(&S) -> C, descriptor: SolutionDescriptor, entity_count_by_descriptor: fn(&S, usize) -> usize, runtime: SolverRuntime, @@ -207,6 +212,10 @@ where This is the binding-oriented entrypoint. It consumes frozen dynamic model parts into the same compiled graph and phase runner used by native models. It accepts no phase builder, selector registration, or alternate construction path. +`constraints` builds a constraint set for a given solution; the runtime calls it +for the working solution and for every evaluator replica, so +`move_thread_count` evaluates dynamic models in parallel. Bindings rebuild +their host constraint set from the solution they receive. The optional qualified provenance changes only the candidate-trace header; it does not select another runner. diff --git a/crates/solverforge-bridge/src/runner.rs b/crates/solverforge-bridge/src/runner.rs index d04a18da..9539af20 100644 --- a/crates/solverforge-bridge/src/runner.rs +++ b/crates/solverforge-bridge/src/runner.rs @@ -63,10 +63,14 @@ where /// /// The one model value is consumed by the canonical compiled runner. There is /// no host-language construction branch or deferred alternate execution path. +/// +/// `constraints` builds a constraint set for a given solution. The runtime +/// calls it for the working solution and for every evaluator replica, so +/// `move_thread_count` evaluates dynamic models in parallel. #[allow(clippy::too_many_arguments)] pub fn try_run_dynamic_solver_with_config_parts( solution: S, - constraints: C, + constraints: fn(&S) -> C, descriptor: SolutionDescriptor, entity_count_by_descriptor: fn(&S, usize) -> usize, runtime: SolverRuntime, diff --git a/crates/solverforge-bridge/src/score.rs b/crates/solverforge-bridge/src/score.rs index 5c94dd80..aadf7e33 100644 --- a/crates/solverforge-bridge/src/score.rs +++ b/crates/solverforge-bridge/src/score.rs @@ -27,6 +27,11 @@ thread_local! { /// This keeps the internal static three-level score contract available to the /// solver while allowing binding-driven solves to present zero and derived /// scores with the host model's declared 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 builds `DynamicScore` values must also enter its +/// declared family inside its own `ConstraintSet` methods. pub fn scoped_dynamic_score_family( family: DynamicScoreFamily, callback: impl FnOnce() -> T, diff --git a/crates/solverforge-macros/WIREFRAME.md b/crates/solverforge-macros/WIREFRAME.md index 9a86c9f9..8f947581 100644 --- a/crates/solverforge-macros/WIREFRAME.md +++ b/crates/solverforge-macros/WIREFRAME.md @@ -254,7 +254,7 @@ semantics should omit `domain = "cvrp"` and declare explicit hook paths instead. - `impl SolvableSolution for T` — delegates to `descriptor()` and `entity_count()` - `impl Solvable for T` (when constraints path specified) — `solve(self, runtime: SolverRuntime, qualified_candidate_trace_provenance: Option)` delegates to `solve_internal()` - `impl Analyzable for T` (when constraints path specified) — `analyze()` creates `ScoreDirector` with canonical shadow support and returns `ScoreAnalysis` -- `fn solve_internal(self, runtime: SolverRuntime, qualified_candidate_trace_provenance: Option)` (when constraints path specified) — loads the configured base `SolverConfig` (or embedded `solver.toml`), applies the optional `config = "..."` callback, and calls hidden `try_run_solver_with_config_and_search`. The generated `__solverforge_search_declaration(config, descriptor)` builds one `RuntimeModel` containing scalar slots plus zero or more owner-specific list slots, delegates scalar hook and scalar-group attachment to the `planning_model!` support impl, sorts those variable slots to the descriptor-backed variable order emitted by the macros, constructs `SearchContext` with `try_new`, and returns the selected custom `Search` declaration or canonical defaults. The compiled runtime owns phase preparation, source binding, construction, local search, and terminal lifecycle; no macro-generated phase sequence exists. +- `fn solve_internal(self, runtime: SolverRuntime, qualified_candidate_trace_provenance: Option)` (when constraints path specified) — loads the configured base `SolverConfig` (or embedded `solver.toml`), applies the optional `config = "..."` callback, and calls hidden `try_run_solver_with_config_and_search`, passing the constraints function as the constraint-set constructor (`fn(&Self) -> _`) used for the working solution and every evaluator replica. The generated `__solverforge_search_declaration(config, descriptor)` builds one `RuntimeModel` containing scalar slots plus zero or more owner-specific list slots, delegates scalar hook and scalar-group attachment to the `planning_model!` support impl, sorts those variable slots to the descriptor-backed variable order emitted by the macros, constructs `SearchContext` with `try_new`, and returns the selected custom `Search` declaration or canonical defaults. The compiled runtime owns phase preparation, source binding, construction, local search, and terminal lifecycle; no macro-generated phase sequence exists. - Public solution source methods for all `#[planning_entity_collection]`, `#[problem_fact_collection]`, and streamable `#[planning_list_element_collection]` fields. Each method is inherent on the solution type, for example `Plan::tasks()`, returns the concrete hidden `SourceExtract &[Task]>` wrapper, and carries hidden `ChangeSource::Descriptor(idx)` for planning entities or `ChangeSource::Static` for facts and list elements. User constraints can call the explicit form `ConstraintFactory::new().for_each(Plan::tasks())` or import the generated `PlanConstraintStreams` trait and call `ConstraintFactory::new().tasks()`. Both paths use the same source metadata and concrete stream types. ### Problem Fact Support Derive diff --git a/crates/solverforge-macros/src/planning_solution/runtime/solve.rs b/crates/solverforge-macros/src/planning_solution/runtime/solve.rs index a5240655..afcceef6 100644 --- a/crates/solverforge-macros/src/planning_solution/runtime/solve.rs +++ b/crates/solverforge-macros/src/planning_solution/runtime/solve.rs @@ -39,7 +39,7 @@ pub(super) fn generate_runtime_solve_internal( #config_expr ::solverforge::__internal::try_run_solver_with_config_and_search( self, - #constraints_fn(), + (|_: &Self| #constraints_fn()) as fn(&Self) -> _, Self::descriptor(), Self::entity_count, runtime, diff --git a/crates/solverforge-macros/src/planning_solution_tests.rs b/crates/solverforge-macros/src/planning_solution_tests.rs index 413f8640..ea83a30b 100644 --- a/crates/solverforge-macros/src/planning_solution_tests.rs +++ b/crates/solverforge-macros/src/planning_solution_tests.rs @@ -65,6 +65,9 @@ fn golden_solution_expansion_loads_solver_config_before_config_callback() { expanded.contains("let config = crate :: config :: for_solution (& self , base_config)") ); assert!(expanded.contains("try_run_solver_with_config_and_search")); + assert!(expanded.contains( + "(| _ : & Self | crate :: constraints :: create_constraints ()) as fn (& Self) -> _" + )); } #[test] diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 889de513..0d461197 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1731,11 +1731,13 @@ partitioned phase starts currently omit a score field. `try_run_solver_with_config_and_search(...) -> RuntimeBuildResult` is the one public configured solve entrypoint used by macro-generated solving. It accepts -the solution/constraints/descriptor, entity counting and logging callbacks, a +the solution, a constraint-set constructor `fn(&S) -> C`, the descriptor, +entity counting and logging callbacks, a `SolverRuntime`, `SolverConfig`, fallback time limit, optional qualified candidate-trace provenance, and a fallible builder for one typed `Search` -declaration. Graph compilation and solve-owned source preparation remain -private; there is no public graph or alternate phase-builder fallback. +declaration. The constructor builds the working solution's constraint set and +every evaluator replica's. Graph compilation and solve-owned source preparation +remain private; there is no public graph or alternate phase-builder fallback. `RuntimeBuildError` is `Declaration { message }`, `Compilation { path, message }`, `Preparation { phase_index, message }`, or diff --git a/crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs b/crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs index 8589fb3c..ed623bb0 100644 --- a/crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs +++ b/crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs @@ -556,6 +556,8 @@ fn zero_variable_log_scale(solution: &ZeroVariableLifecycleSolution) { solution.gate.wait_for_permit(); } +fn zero_variable_constraints(_solution: &ZeroVariableLifecycleSolution) {} + impl Solvable for ZeroVariableLifecycleSolution { fn solve( self, @@ -564,7 +566,7 @@ impl Solvable for ZeroVariableLifecycleSolution { ) { let result = try_run_solver_with_config_and_search( self, - (), + zero_variable_constraints, zero_variable_solution_descriptor(), zero_variable_entity_count, runtime, diff --git a/crates/solverforge-solver/src/run.rs b/crates/solverforge-solver/src/run.rs index 9e0223e1..4e9014ac 100644 --- a/crates/solverforge-solver/src/run.rs +++ b/crates/solverforge-solver/src/run.rs @@ -419,10 +419,14 @@ fn load_solver_config_from(path: impl AsRef) -> SolverConfig { /// the compiled graph. Every model, including a zero-work model, follows this /// one lifecycle; the public API never exposes a graph, prepared source /// catalog, or phase-builder fallback. +/// +/// `constraints` builds a constraint set for a given solution. It builds the +/// working solution's constraint set and every evaluator replica's, so parallel +/// local-search evaluation can fork replicas for any model. #[allow(clippy::too_many_arguments)] pub fn try_run_solver_with_config_and_search( solution: S, - constraints: C, + constraints: fn(&S) -> C, descriptor: SolutionDescriptor, entity_count_by_descriptor: fn(&S, usize) -> usize, runtime: SolverRuntime, @@ -470,7 +474,7 @@ where #[allow(clippy::too_many_arguments)] fn try_run_solver_with_config_and_search_request( solution: S, - constraints: C, + constraints: fn(&S) -> C, descriptor: SolutionDescriptor, entity_count_by_descriptor: fn(&S, usize) -> usize, runtime: SolverRuntime, @@ -531,7 +535,7 @@ where #[allow(clippy::too_many_arguments)] fn try_run_solver_with_candidate_trace_request( solution: S, - constraints: C, + constraints: fn(&S) -> C, descriptor: SolutionDescriptor, entity_count_by_descriptor: fn(&S, usize) -> usize, runtime: SolverRuntime, @@ -549,12 +553,14 @@ where BuildRunner: FnOnce(&SolverConfig, &SolutionDescriptor) -> RuntimeBuildResult, { log_scale(&solution); + let main_constraints = constraints(&solution); let director = ScoreDirector::with_descriptor( solution, - constraints, + main_constraints, descriptor.clone(), entity_count_by_descriptor, - ); + ) + .with_replica_constraints(constraints); let (termination, time_limit) = build_termination::(&config, default_time_limit_secs); let execution_policy = diff --git a/crates/solverforge-solver/src/run_tests.rs b/crates/solverforge-solver/src/run_tests.rs index 8e44fb1b..9d97cb62 100644 --- a/crates/solverforge-solver/src/run_tests.rs +++ b/crates/solverforge-solver/src/run_tests.rs @@ -161,13 +161,17 @@ fn test_entity_count(_solution: &TestSolution, _descriptor_index: usize) -> usiz fn noop_log_scale(_solution: &TestSolution) {} +fn test_constraints(_solution: &TestSolution) -> ScoreFromSolutionConstraints { + ScoreFromSolutionConstraints +} + #[test] fn zero_variable_run_builds_the_normal_graph_and_search_declaration() { let declarations = Arc::new(AtomicUsize::new(0)); let observed = Arc::clone(&declarations); let result = try_run_solver_with_config_and_search( TestSolution { score: None }, - ScoreFromSolutionConstraints, + test_constraints, test_descriptor(), test_entity_count, SolverRuntime::detached(), From 845a57134eaec03b200af3685476e7cb9629b720 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:46:51 +0200 Subject: [PATCH 03/30] refactor(solver): split the local-search trial kernel from its accounting 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. --- .../src/phase/localsearch/evaluation.rs | 121 +++++++++++++----- .../src/phase/localsearch/phase/candidates.rs | 2 +- .../src/phase/localsearch/vnd/phase.rs | 2 +- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs index 9324c79b..ed41a16d 100644 --- a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs +++ b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use solverforge_core::domain::PlanningSolution; use solverforge_core::score::Score; @@ -17,6 +17,14 @@ pub(crate) enum CandidateEvaluation { RejectedByScoreImprovement(Sc), } +/* Director-only result of one trial transaction. Statistics are recorded by +`account_trial` on the thread that owns the step scope. +*/ +pub(crate) enum TrialOutcome { + NotDoable, + Scored(Sc), +} + #[inline] pub(crate) fn evaluate_candidate( mov: &MoveCandidateRef<'_, S, M>, @@ -32,34 +40,83 @@ where ProgressCb: ProgressCallback, M: Move, { - if move_changes_pinned(mov, step_scope.score_director()) - || !mov.is_doable_with_working_preview(step_scope.score_director_mut()) - { - let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, evaluation_started); - step_scope - .phase_scope_mut() - .record_move_kind_evaluated_unscored(move_label); - if let Some(selector_index) = selector_index { + let outcome = run_trial(mov, step_scope.score_director_mut(), journal_pool); + account_trial( + mov, + step_scope, + reference_score, + selector_index, + evaluation_started.elapsed(), + outcome, + ) +} + +/* Runs the trial transaction on `director`: the pinned/doable check, then +apply, score, undo and restore. Touches nothing but the director and the +undo journal, so it can run on an evaluator replica. +*/ +#[inline] +pub(crate) fn run_trial( + mov: &MoveCandidateRef<'_, S, M>, + director: &mut D, + journal_pool: &mut Vec>, +) -> TrialOutcome +where + S: PlanningSolution, + D: Director, + M: Move, +{ + if move_changes_pinned(mov, director) || !mov.is_doable_with_working_preview(director) { + return TrialOutcome::NotDoable; + } + + let score_state = director.snapshot_score_state(); + let undo = mov.do_move_with_scratch(director, journal_pool); + let move_score = director.calculate_score(); + mov.undo_move_with_scratch(director, undo, journal_pool); + director.restore_score_state(score_state); + TrialOutcome::Scored(move_score) +} + +/* Records the statistics and improvement-gate decisions for one trial +outcome, in the order the sequential evaluation loop has always used. +*/ +#[inline] +pub(crate) fn account_trial( + mov: &MoveCandidateRef<'_, S, M>, + step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, + reference_score: S::Score, + selector_index: Option, + elapsed: Duration, + outcome: TrialOutcome, +) -> CandidateEvaluation +where + S: PlanningSolution, + D: Director, + ProgressCb: ProgressCallback, + M: Move, +{ + let move_score = match outcome { + TrialOutcome::NotDoable => { + let move_label = mov.telemetry_label(); + record_evaluated_move(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() - .record_selector_move_not_doable(selector_index); - } else { - step_scope.phase_scope_mut().record_move_not_doable(); + .record_move_kind_evaluated_unscored(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_not_doable(selector_index); + } else { + step_scope.phase_scope_mut().record_move_not_doable(); + } + step_scope + .phase_scope_mut() + .record_move_kind_not_doable(move_label); + return CandidateEvaluation::NotDoable; } - step_scope - .phase_scope_mut() - .record_move_kind_not_doable(move_label); - return CandidateEvaluation::NotDoable; - } - - let score_state = step_scope.score_director().snapshot_score_state(); - let undo = mov.do_move_with_scratch(step_scope.score_director_mut(), journal_pool); - let move_score = step_scope.score_director_mut().calculate_score(); - mov.undo_move_with_scratch(step_scope.score_director_mut(), undo, journal_pool); - step_scope - .score_director_mut() - .restore_score_state(score_state); + TrialOutcome::Scored(move_score) => move_score, + }; step_scope.phase_scope_mut().record_score_calculation(); @@ -79,7 +136,7 @@ where if mov.requires_hard_improvement() && hard_delta != Some(HardScoreDelta::Improving) { let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, evaluation_started); + record_evaluated_move(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); @@ -98,7 +155,7 @@ where if mov.requires_score_improvement() && move_score <= reference_score { let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, evaluation_started); + record_evaluated_move(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); @@ -122,7 +179,7 @@ where pub(crate) fn record_evaluated_move( step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, selector_index: Option, - evaluation_started: Instant, + elapsed: Duration, ) where S: PlanningSolution, D: Director, @@ -131,10 +188,8 @@ pub(crate) fn record_evaluated_move( if let Some(selector_index) = selector_index { step_scope .phase_scope_mut() - .record_selector_evaluated_move(selector_index, evaluation_started.elapsed()); + .record_selector_evaluated_move(selector_index, elapsed); } else { - step_scope - .phase_scope_mut() - .record_evaluated_move(evaluation_started.elapsed()); + step_scope.phase_scope_mut().record_evaluated_move(elapsed); } } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 6e722336..bac10329 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -217,7 +217,7 @@ where let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_ref()); - record_evaluated_move(step_scope, selector_index, evaluation_started); + record_evaluated_move(step_scope, selector_index, evaluation_started.elapsed()); if accepted { step_scope .phase_scope_mut() diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs index 38d98c48..4a45ab0b 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs @@ -338,7 +338,7 @@ where } }; - record_evaluated_move(step_scope, selector_index, evaluation_started); + record_evaluated_move(step_scope, selector_index, evaluation_started.elapsed()); progress.record_evaluated(); progress.maybe_report(step_scope); From c5584eacf34576cb0edf83782b5813ddcc01e90c Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:48:58 +0200 Subject: [PATCH 04/30] refactor(solver): extract local-search candidate generation and consumption 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. --- crates/solverforge-solver/WIREFRAME.md | 5 +- .../src/phase/localsearch/phase.rs | 1 + .../src/phase/localsearch/phase/candidates.rs | 233 ++------------- .../src/phase/localsearch/phase/decision.rs | 282 ++++++++++++++++++ 4 files changed, 314 insertions(+), 207 deletions(-) create mode 100644 crates/solverforge-solver/src/phase/localsearch/phase/decision.rs diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 0d461197..4a5ec46f 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -266,8 +266,11 @@ src/ │ │ └── grouped_scalar/placer_stream.rs — Concrete candidate and assignment placement stream helpers │ ├── localsearch/ │ │ ├── mod.rs — Acceptor, local-search acceptor/forager, and LocalSearchPhase re-exports -│ │ ├── evaluation.rs — Shared local-search candidate evaluation and hard-delta classification +│ │ ├── evaluation.rs — Trial kernel (run_trial), candidate accounting (account_trial), and hard-delta classification │ │ ├── phase.rs — LocalSearchPhase +│ │ ├── phase/candidates.rs — Candidate loop for one step +│ │ ├── phase/decision.rs — Generated-candidate records and in-order candidate consumption +│ │ ├── phase/step.rs — One step: candidate selection and commit │ │ ├── phase/tests.rs — Tests │ │ ├── forager.rs — LocalSearchForager trait, AcceptedCountForager, FirstAcceptedForager, BestScoreForager, re-exports │ │ ├── forager/any_tests.rs — AnyForager dispatch tests diff --git a/crates/solverforge-solver/src/phase/localsearch/phase.rs b/crates/solverforge-solver/src/phase/localsearch/phase.rs index c1f6f720..61c7e3fa 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase.rs @@ -18,6 +18,7 @@ use crate::scope::{PhaseScope, ProgressCallback, SolverScope}; use crate::stats::{format_duration, whole_units_per_second, CandidateTracePullToken}; mod candidates; +mod decision; mod step; use step::{execute_step, StepOutcome}; diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index bac10329..6d3a7b57 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -1,9 +1,7 @@ //! Candidate streaming for one local-search step. use solverforge_core::domain::PlanningSolution; -use solverforge_core::score::Score; use solverforge_scoring::Director; -use tracing::trace; use crate::heuristic::r#move::Move; use crate::heuristic::selector::move_selector::{ @@ -12,17 +10,13 @@ use crate::heuristic::selector::move_selector::{ use crate::phase::control::{ should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, }; -use crate::phase::localsearch::evaluation::{ - evaluate_candidate, record_evaluated_move, CandidateEvaluation, -}; -use crate::phase::localsearch::{Acceptor, ForagerDecision, LocalSearchForager}; +use crate::phase::localsearch::evaluation::run_trial; +use crate::phase::localsearch::{Acceptor, LocalSearchForager}; use crate::scope::{ProgressCallback, StepScope}; -use crate::stats::{CandidateTraceDisposition, CandidateTracePullToken, CandidateTraceSource}; - -use super::{candidate_selector_label, take_trace_token, StepMoveLabelCounts}; +use crate::stats::{CandidateTraceDisposition, CandidateTracePullToken}; -const PROGRESS_POLL_INTERVAL: u64 = 256; -// Candidate iterations between control polls in the step loop. +use super::decision::{consume_candidate, record_generated_candidate}; +use super::StepMoveLabelCounts; pub(super) struct CandidateLoopState { pub(super) interrupted: bool, @@ -99,42 +93,13 @@ where else { break; }; - let selector_index = cursor.selector_index(candidate_id); - let mov = cursor - .candidate(candidate_id) - .expect("discovered candidate id must remain borrowable"); - let move_label = mov.telemetry_label(); - let selector_label = selector_index.map(|_| candidate_selector_label(&mov)); - let trace_token = step_scope.phase_scope_mut().record_candidate_pull( - CandidateTraceSource::LocalSearch, - selector_index, - candidate_id.index(), - None, - &mov, + let slot = record_generated_candidate( + &*cursor, + candidate_id, + generation_started, + step_scope, + &mut state, ); - let generation_elapsed = generation_started.elapsed(); - state.generated_moves += 1; - step_scope - .phase_scope_mut() - .record_move_kind_generated(move_label); - if let Some(selector_index) = selector_index { - step_scope - .phase_scope_mut() - .record_selector_generated_move_with_label( - selector_index, - selector_label.as_deref().unwrap_or("selector"), - generation_elapsed, - ); - } else { - step_scope - .phase_scope_mut() - .record_generated_move(generation_elapsed); - } - if step_scope.progress_polling_required() - && state.generated_moves.is_multiple_of(PROGRESS_POLL_INTERVAL) - { - step_scope.phase_scope_mut().report_progress_if_due(); - } if should_interrupt_before_evaluation(step_scope) || step_scope @@ -142,7 +107,7 @@ where .solver_scope_mut() .should_terminate() { - if let Some(token) = trace_token { + if let Some(token) = slot.trace_token { step_scope .phase_scope_mut() .record_candidate_trace_disposition( @@ -153,171 +118,27 @@ where state.interrupted = true; break; } - state.evaluated_moves += 1; let evaluation_started = std::time::Instant::now(); - let move_score = match evaluate_candidate( - &mov, + let outcome = { + let mov = cursor + .candidate(slot.id) + .expect("discovered candidate id must remain borrowable"); + run_trial(&mov, step_scope.score_director_mut(), &mut journal_pool) + }; + consume_candidate( + cursor, step_scope, + &slot, + outcome, + evaluation_started.elapsed(), last_step_score, - selector_index, - evaluation_started, - &mut journal_pool, - ) { - CandidateEvaluation::Scored(score) => { - if let Some(token) = trace_token { - step_scope - .phase_scope_mut() - .record_candidate_trace_disposition( - token, - CandidateTraceDisposition::Evaluated, - ); - } - step_scope - .phase_scope_mut() - .record_move_kind_evaluated(move_label, score.compare(&last_step_score)); - score - } - CandidateEvaluation::NotDoable => { - record_rejected_trace( - step_scope, - trace_token, - CandidateTraceDisposition::NotDoable, - ); - assert!(cursor.release_candidate(candidate_id)); - continue; - } - CandidateEvaluation::RejectedByHardImprovement(_) => { - record_rejected_trace( - step_scope, - trace_token, - CandidateTraceDisposition::RejectedByHardImprovement, - ); - assert!(cursor.release_candidate(candidate_id)); - continue; - } - CandidateEvaluation::RejectedByScoreImprovement(_) => { - record_rejected_trace( - step_scope, - trace_token, - CandidateTraceDisposition::RejectedByScoreImprovement, - ); - assert!(cursor.release_candidate(candidate_id)); - continue; - } - }; - let move_signature = if requires_move_signatures { - match mov.borrowed_tabu_signature() { - Some(borrowed) => Some(borrowed.clone()), - None => Some(mov.tabu_signature(step_scope.score_director())), - } - } else { - None - }; - - let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_ref()); - - record_evaluated_move(step_scope, selector_index, evaluation_started.elapsed()); - if accepted { - step_scope - .phase_scope_mut() - .record_move_kind_accepted(move_label); - state.accepted_labels.record(move_label); - if let Some(selector_index) = selector_index { - step_scope - .phase_scope_mut() - .record_selector_move_accepted(selector_index); - } else { - step_scope.phase_scope_mut().record_move_accepted(); - } - state.accepted_moves += 1; - } else { - if let Some(token) = trace_token { - step_scope - .phase_scope_mut() - .record_candidate_trace_disposition( - token, - CandidateTraceDisposition::AcceptorRejected, - ); - } - if let Some(selector_index) = selector_index { - step_scope - .phase_scope_mut() - .record_selector_move_acceptor_rejected(selector_index); - } else { - step_scope.phase_scope_mut().record_move_acceptor_rejected(); - } - step_scope - .phase_scope_mut() - .record_move_kind_acceptor_rejected( - move_label, - move_score.compare(&last_step_score), - ); - } - - trace!( - event = "step", - step = step_scope.step_index(), - move_index = candidate_id.index(), - score = %move_score, - accepted = accepted, + acceptor, + forager, + requires_move_signatures, + &mut state, ); - - if accepted { - match forager.add_move_index(candidate_id, move_score) { - ForagerDecision::Keep => { - if let Some(token) = trace_token { - state.accepted_trace_tokens.push((candidate_id, token)); - } - } - ForagerDecision::Release => { - if let Some(token) = trace_token { - step_scope - .phase_scope_mut() - .record_candidate_trace_disposition( - token, - CandidateTraceDisposition::ForagerIgnored, - ); - } - assert!(cursor.release_candidate(candidate_id)); - } - ForagerDecision::Replace(replaced_id) => { - if let Some(token) = - take_trace_token(&mut state.accepted_trace_tokens, replaced_id) - { - step_scope - .phase_scope_mut() - .record_candidate_trace_disposition( - token, - CandidateTraceDisposition::ForagerIgnored, - ); - } - if let Some(token) = trace_token { - state.accepted_trace_tokens.push((candidate_id, token)); - } - assert!(cursor.release_candidate(replaced_id)); - } - } - } else { - assert!(cursor.release_candidate(candidate_id)); - } } state } - -fn record_rejected_trace( - step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, - trace_token: Option, - disposition: CandidateTraceDisposition, -) where - S: PlanningSolution, - D: Director, - BestCb: ProgressCallback, -{ - if let Some(token) = trace_token { - let phase_scope = step_scope.phase_scope_mut(); - phase_scope.record_candidate_trace_disposition(token, CandidateTraceDisposition::Evaluated); - phase_scope.record_candidate_trace_disposition(token, disposition); - } -} diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs new file mode 100644 index 00000000..c27e5263 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -0,0 +1,282 @@ +//! Candidate generation records and in-order candidate consumption for one +//! local-search step. + +use std::time::{Duration, Instant}; + +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::Score; +use solverforge_scoring::Director; +use tracing::trace; + +use crate::heuristic::r#move::Move; +use crate::heuristic::selector::move_selector::{CandidateId, ResourceMoveCursor}; +use crate::phase::localsearch::evaluation::{ + account_trial, record_evaluated_move, CandidateEvaluation, TrialOutcome, +}; +use crate::phase::localsearch::{Acceptor, ForagerDecision, LocalSearchForager}; +use crate::scope::{ProgressCallback, StepScope}; +use crate::stats::{CandidateTraceDisposition, CandidateTracePullToken, CandidateTraceSource}; + +use super::candidates::CandidateLoopState; +use super::{candidate_selector_label, take_trace_token}; + +const PROGRESS_POLL_INTERVAL: u64 = 256; + +/// A generated candidate waiting for its trial result. +pub(super) struct WindowSlot { + pub(super) id: CandidateId, + pub(super) selector_index: Option, + pub(super) move_label: &'static str, + pub(super) trace_token: Option, +} + +/// Records one pulled candidate: trace pull, generation counters and +/// telemetry, and the periodic progress poll. +pub(super) fn record_generated_candidate( + cursor: &C, + candidate_id: CandidateId, + generation_started: Instant, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + state: &mut CandidateLoopState, +) -> WindowSlot +where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + C: ResourceMoveCursor, +{ + let selector_index = cursor.selector_index(candidate_id); + let mov = cursor + .candidate(candidate_id) + .expect("discovered candidate id must remain borrowable"); + let move_label = mov.telemetry_label(); + let selector_label = selector_index.map(|_| candidate_selector_label(&mov)); + let trace_token = step_scope.phase_scope_mut().record_candidate_pull( + CandidateTraceSource::LocalSearch, + selector_index, + candidate_id.index(), + None, + &mov, + ); + let generation_elapsed = generation_started.elapsed(); + state.generated_moves += 1; + step_scope + .phase_scope_mut() + .record_move_kind_generated(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_generated_move_with_label( + selector_index, + selector_label.as_deref().unwrap_or("selector"), + generation_elapsed, + ); + } else { + step_scope + .phase_scope_mut() + .record_generated_move(generation_elapsed); + } + if step_scope.progress_polling_required() + && state.generated_moves.is_multiple_of(PROGRESS_POLL_INTERVAL) + { + step_scope.phase_scope_mut().report_progress_if_due(); + } + WindowSlot { + id: candidate_id, + selector_index, + move_label, + trace_token, + } +} + +/// Feeds one trial result through accounting, the acceptor and the forager, +/// and releases the candidate when the step no longer needs it. +#[allow(clippy::too_many_arguments)] +pub(super) fn consume_candidate( + cursor: &mut C, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + slot: &WindowSlot, + outcome: TrialOutcome, + elapsed: Duration, + last_step_score: S::Score, + acceptor: &mut A, + forager: &mut Fo, + requires_move_signatures: bool, + state: &mut CandidateLoopState, +) where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + C: ResourceMoveCursor, + A: Acceptor, + Fo: LocalSearchForager, +{ + let candidate_id = slot.id; + let selector_index = slot.selector_index; + let move_label = slot.move_label; + let trace_token = slot.trace_token; + state.evaluated_moves += 1; + + let mov = cursor + .candidate(candidate_id) + .expect("discovered candidate id must remain borrowable"); + let move_score = match account_trial( + &mov, + step_scope, + last_step_score, + selector_index, + elapsed, + outcome, + ) { + CandidateEvaluation::Scored(score) => { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::Evaluated, + ); + } + step_scope + .phase_scope_mut() + .record_move_kind_evaluated(move_label, score.compare(&last_step_score)); + score + } + CandidateEvaluation::NotDoable => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::NotDoable, + ); + assert!(cursor.release_candidate(candidate_id)); + return; + } + CandidateEvaluation::RejectedByHardImprovement(_) => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::RejectedByHardImprovement, + ); + assert!(cursor.release_candidate(candidate_id)); + return; + } + CandidateEvaluation::RejectedByScoreImprovement(_) => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::RejectedByScoreImprovement, + ); + assert!(cursor.release_candidate(candidate_id)); + return; + } + }; + let move_signature = if requires_move_signatures { + match mov.borrowed_tabu_signature() { + Some(borrowed) => Some(borrowed.clone()), + None => Some(mov.tabu_signature(step_scope.score_director())), + } + } else { + None + }; + + let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_ref()); + + record_evaluated_move(step_scope, selector_index, elapsed); + if accepted { + step_scope + .phase_scope_mut() + .record_move_kind_accepted(move_label); + state.accepted_labels.record(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_accepted(selector_index); + } else { + step_scope.phase_scope_mut().record_move_accepted(); + } + state.accepted_moves += 1; + } else { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::AcceptorRejected, + ); + } + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_acceptor_rejected(selector_index); + } else { + step_scope.phase_scope_mut().record_move_acceptor_rejected(); + } + step_scope + .phase_scope_mut() + .record_move_kind_acceptor_rejected(move_label, move_score.compare(&last_step_score)); + } + + trace!( + event = "step", + step = step_scope.step_index(), + move_index = candidate_id.index(), + score = %move_score, + accepted = accepted, + ); + + if accepted { + match forager.add_move_index(candidate_id, move_score) { + ForagerDecision::Keep => { + if let Some(token) = trace_token { + state.accepted_trace_tokens.push((candidate_id, token)); + } + } + ForagerDecision::Release => { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + assert!(cursor.release_candidate(candidate_id)); + } + ForagerDecision::Replace(replaced_id) => { + if let Some(token) = take_trace_token(&mut state.accepted_trace_tokens, replaced_id) + { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + if let Some(token) = trace_token { + state.accepted_trace_tokens.push((candidate_id, token)); + } + assert!(cursor.release_candidate(replaced_id)); + } + } + } else { + assert!(cursor.release_candidate(candidate_id)); + } +} + +fn record_rejected_trace( + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + trace_token: Option, + disposition: CandidateTraceDisposition, +) where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, +{ + if let Some(token) = trace_token { + let phase_scope = step_scope.phase_scope_mut(); + phase_scope.record_candidate_trace_disposition(token, CandidateTraceDisposition::Evaluated); + phase_scope.record_candidate_trace_disposition(token, disposition); + } +} From 8cece00eea6be25fc63eca66ff29288542de138d Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:50:47 +0200 Subject: [PATCH 05/30] feat(solver): report speculative evaluation in telemetry and candidate 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. --- crates/solverforge-solver/WIREFRAME.md | 8 ++++++-- crates/solverforge-solver/src/scope/phase.rs | 6 ++++++ .../src/scope/solver/scope_progress.rs | 4 ++++ .../src/stats/candidate_trace.rs | 5 +++++ crates/solverforge-solver/src/stats/solver.rs | 10 ++++++++++ crates/solverforge-solver/src/stats/telemetry.rs | 4 ++++ crates/solverforge-solver/src/stats/tests.rs | 15 +++++++++++++++ 7 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 4a5ec46f..3444f44d 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1601,7 +1601,10 @@ time, evaluation time, acceptance rate, selector-level telemetry, and exact `Throughput { count, elapsed }` views for generated/evaluated work. Human-facing `moves/s` is derived only at log/console formatting edges. `moves_generated` counts candidates actually yielded by a runtime cursor; it -does not count an unrequested logical tail. Selector `size()` and explicit full +does not count an unrequested logical tail. `moves_speculative` counts +candidates that parallel local-search evaluation evaluated ahead of the +sequential decision point and then discarded; it is zero for sequential +evaluation and never feeds `moves_evaluated` or `score_calculations`. Selector `size()` and explicit full cursor exhaustion cover logical neighborhood size and canonical order. `SolverTelemetry` snapshots expose the same counters plus an optional @@ -1650,7 +1653,8 @@ ordered `CandidateTraceDisposition` transitions. Sources cover construction, local search, VND, generic K-opt, and the specialized list construction/search trial paths. Dispositions distinguish interruption, evaluation, not-doable, hard/score-improvement rejection, acceptor rejection, forager loss, selection, -and application. +application, and `DiscardedSpeculative` (pulled into a parallel evaluation +window, then discarded when the step ended before it was consumed). Logical identities are `CandidateTraceIdentity::Operation` or ordered `Composite`. `CandidateTraceOperationIdentity` carries descriptor, optional diff --git a/crates/solverforge-solver/src/scope/phase.rs b/crates/solverforge-solver/src/scope/phase.rs index d584c67e..62da0e6f 100644 --- a/crates/solverforge-solver/src/scope/phase.rs +++ b/crates/solverforge-solver/src/scope/phase.rs @@ -488,6 +488,12 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> self.solver_scope.record_score_calculation(); } + /// Records a candidate evaluated ahead of the sequential decision point by + /// parallel evaluation and then discarded. + pub fn record_move_speculative(&mut self) { + self.solver_scope.record_move_speculative(); + } + pub fn record_construction_slot_assigned(&mut self) { self.stats.record_construction_slot_assigned(); self.solver_scope diff --git a/crates/solverforge-solver/src/scope/solver/scope_progress.rs b/crates/solverforge-solver/src/scope/solver/scope_progress.rs index 08af659a..da1308cb 100644 --- a/crates/solverforge-solver/src/scope/solver/scope_progress.rs +++ b/crates/solverforge-solver/src/scope/solver/scope_progress.rs @@ -520,6 +520,10 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } } + pub(crate) fn record_move_speculative(&mut self) { + self.stats.record_move_speculative(); + } + pub(crate) fn record_score_calculation(&mut self) { self.stats.record_score_calculation(); if let Some(phase_budget) = self.phase_budget { diff --git a/crates/solverforge-solver/src/stats/candidate_trace.rs b/crates/solverforge-solver/src/stats/candidate_trace.rs index d5649d4d..a8543f8b 100644 --- a/crates/solverforge-solver/src/stats/candidate_trace.rs +++ b/crates/solverforge-solver/src/stats/candidate_trace.rs @@ -525,6 +525,9 @@ pub enum CandidateTraceDisposition { Selected, /// The selected candidate was applied to the working solution. Applied, + /// Parallel evaluation pulled this candidate ahead of the sequential + /// decision point; the step ended before the candidate was consumed. + DiscardedSpeculative, } impl CandidateTraceDisposition { @@ -539,6 +542,7 @@ impl CandidateTraceDisposition { Self::ForagerIgnored => 7, Self::Selected => 8, Self::Applied => 9, + Self::DiscardedSpeculative => 10, } } @@ -552,6 +556,7 @@ impl CandidateTraceDisposition { | Self::AcceptorRejected | Self::ForagerIgnored | Self::Applied + | Self::DiscardedSpeculative ) } } diff --git a/crates/solverforge-solver/src/stats/solver.rs b/crates/solverforge-solver/src/stats/solver.rs index 99c9b117..cf49a2d1 100644 --- a/crates/solverforge-solver/src/stats/solver.rs +++ b/crates/solverforge-solver/src/stats/solver.rs @@ -21,6 +21,9 @@ pub struct SolverStats { pub moves_generated: u64, // Total moves evaluated across all phases. pub moves_evaluated: u64, + /// Candidates evaluated ahead of the sequential decision point by parallel + /// evaluation and then discarded. Zero for sequential evaluation. + pub moves_speculative: u64, // Total moves accepted across all phases. pub moves_accepted: u64, // Total moves applied across all phases. @@ -208,6 +211,12 @@ impl SolverStats { self.score_calculations += 1; } + /// Records a candidate that parallel evaluation evaluated ahead of the + /// sequential decision point and then discarded. + pub fn record_move_speculative(&mut self) { + self.moves_speculative += 1; + } + pub fn record_construction_slot_assigned(&mut self) { self.construction_slots_assigned += 1; } @@ -281,6 +290,7 @@ impl SolverStats { step_count: self.step_count, moves_generated: self.moves_generated, moves_evaluated: self.moves_evaluated, + moves_speculative: self.moves_speculative, moves_accepted: self.moves_accepted, moves_applied: self.moves_applied, moves_score_improving: self.moves_score_improving(), diff --git a/crates/solverforge-solver/src/stats/telemetry.rs b/crates/solverforge-solver/src/stats/telemetry.rs index 85874d14..ebbcd31b 100644 --- a/crates/solverforge-solver/src/stats/telemetry.rs +++ b/crates/solverforge-solver/src/stats/telemetry.rs @@ -120,6 +120,9 @@ pub struct SolverTelemetry { /// Exhaust a cursor or query selector sizing separately for logical size. pub moves_generated: u64, pub moves_evaluated: u64, + /// Candidates evaluated ahead of the sequential decision point by parallel + /// evaluation and then discarded. Zero for sequential evaluation. + pub moves_speculative: u64, pub moves_accepted: u64, pub moves_applied: u64, pub moves_score_improving: u64, @@ -159,6 +162,7 @@ impl SolverTelemetry { step_count: 0, moves_generated: 0, moves_evaluated: 0, + moves_speculative: 0, moves_accepted: 0, moves_applied: 0, moves_score_improving: 0, diff --git a/crates/solverforge-solver/src/stats/tests.rs b/crates/solverforge-solver/src/stats/tests.rs index 2a06a840..dc4f0b97 100644 --- a/crates/solverforge-solver/src/stats/tests.rs +++ b/crates/solverforge-solver/src/stats/tests.rs @@ -355,3 +355,18 @@ fn candidate_trace_plan_finalization_is_one_way() { trace.finalize_resolved_phase_plan(plan.clone()); trace.finalize_resolved_phase_plan(plan); } + +#[test] +fn speculative_moves_reach_the_telemetry_snapshot() { + let mut stats = SolverStats::default(); + stats.start(); + stats.record_evaluated_move(Duration::from_millis(1)); + stats.record_move_speculative(); + stats.record_move_speculative(); + + let snapshot = stats.snapshot(); + + assert_eq!(snapshot.moves_evaluated, 1); + assert_eq!(snapshot.moves_speculative, 2); + assert_eq!(SolverTelemetry::new_const().moves_speculative, 0); +} From c01efacf4c17fb81c36e3d7937c7732227d3b130 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:55:56 +0200 Subject: [PATCH 06/30] feat(solver): parallel evaluation windows for acceptor-forager local 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. --- AGENTS.md | 9 +- crates/solverforge-solver/WIREFRAME.md | 32 ++ .../src/phase/localsearch/mod.rs | 1 + .../src/phase/localsearch/parallel/budget.rs | 30 ++ .../src/phase/localsearch/parallel/mod.rs | 17 ++ .../src/phase/localsearch/parallel/pool.rs | 139 +++++++++ .../src/phase/localsearch/parallel/tests.rs | 274 ++++++++++++++++++ .../src/phase/localsearch/parallel/window.rs | 86 ++++++ .../src/phase/localsearch/phase.rs | 5 + .../src/phase/localsearch/phase/candidates.rs | 174 +++++++---- .../src/phase/localsearch/phase/decision.rs | 64 ++++ .../src/phase/localsearch/phase/step.rs | 11 + .../compiler/executor/runner/local_search.rs | 1 + .../src/scope/solver/scope_core.rs | 13 + .../src/scope/solver/scope_progress.rs | 1 + crates/solverforge-solver/src/scope/tests.rs | 33 +++ 16 files changed, 836 insertions(+), 54 deletions(-) create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/budget.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/window.rs diff --git a/AGENTS.md b/AGENTS.md index 1eab4b9c..3586e1d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,11 @@ composition. Clone is acceptable only for: 1. Solution snapshots sent through channels. -2. Partitioned search creating partition copies. +2. Parallel search creating worker-private solution copies: partitioned + search's partition copies, and parallel local-search evaluation's + evaluator replicas — one per evaluator beyond the main director, made at + phase start and again after the working solution is written outside the + parallel loop's own commits. 3. Explicit user-owned result snapshots outside retained scoring state, such as `CollectedVec::to_vec()` when the caller deliberately asks for an owned `Vec`. @@ -276,6 +280,9 @@ Use `rayon` for CPU-bound work and `tokio` for async coordination. - Do not add `std::thread::spawn`. - Do not add `std::thread::sleep`. - Prefer ownership transfer over callback APIs that only expose `&Self`. +- Parallel local-search evaluation runs one `rayon::scope` per evaluation + window on the solve thread's current pool; do not add dedicated pools or + blocking waits inside evaluation workers. ### Configured Completion diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 3444f44d..ceb2126c 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -276,6 +276,11 @@ src/ │ │ ├── forager/any_tests.rs — AnyForager dispatch tests │ │ ├── forager/improving.rs — FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager │ │ ├── forager/tests.rs — Tests +│ │ ├── parallel/mod.rs — Parallel evaluation module declarations +│ │ ├── parallel/budget.rs — Remaining in-phase evaluation budget for window sizing +│ │ ├── parallel/pool.rs — EvaluatorPool: evaluator replicas, sync, and post-commit advance +│ │ ├── parallel/window.rs — Window evaluation across the main director and replicas +│ │ ├── parallel/tests.rs — Tests │ │ └── acceptor/ │ │ ├── mod.rs — Acceptor module declarations and re-exports │ │ ├── traits.rs — Acceptor trait @@ -1248,6 +1253,33 @@ boundaries report it automatically, and bounded inner candidate scans poll it for prompt progress and interruption; publication is not gated on accumulating a fixed-size candidate batch. +**Parallel evaluation.** Acceptor-forager local search in the compiled runtime +can evaluate candidates on several cores within one solve. Each step produces a +window of candidates serially on the main director, in canonical order; +evaluates the window on the main director plus `n - 1` persistent evaluator +replicas (one `rayon::scope` per window, slots handed out through one atomic +counter); and consumes the results in candidate order through the unchanged +acceptor and forager. The selected move is applied to every replica by borrow +before the owned commit, and each replica must reach the committed step score. +Replicas are forked from the working solution through +`Director::fork_replica`, again whenever the working solution was written +outside the loop's own commits. Contract: + +- One evaluator runs the sequential loop: same candidates, checks, order, and + telemetry. +- The window holds 32 candidates whenever two or more evaluators run, so the + trajectory at fixed work is identical for every evaluator count of two or + more. Selectors that keep no stream state across steps also match the + sequential trajectory; ruin selectors (cross-step RNG streams) and lazily + activated providers follow a different, equally reproducible trajectory. +- `moves_evaluated`, `score_calculations`, and the move-count and + score-calculation limits count only consumed candidates; the window never + exceeds the remaining in-phase move or score-calculation budget. +- Candidates evaluated ahead of the decision point and then discarded are + counted in `moves_speculative` and traced as `DiscardedSpeculative`. +- `LocalSearchPhase`, VND, construction, exhaustive search, and partitioned + child solves evaluate sequentially. + When runtime phase configuration is omitted, SolverForge always builds state-aware construction. It appends the model-aware streaming local-search default only when the top-level solver termination parses to an effective diff --git a/crates/solverforge-solver/src/phase/localsearch/mod.rs b/crates/solverforge-solver/src/phase/localsearch/mod.rs index 8731fa3f..db34c473 100644 --- a/crates/solverforge-solver/src/phase/localsearch/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/mod.rs @@ -8,6 +8,7 @@ mod acceptor; mod cursor_source; mod evaluation; mod forager; +mod parallel; mod phase; pub(crate) mod vnd; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/budget.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/budget.rs new file mode 100644 index 00000000..7e372b78 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/budget.rs @@ -0,0 +1,30 @@ +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::Director; + +use crate::scope::{ProgressCallback, SolverScope}; + +/* Remaining in-phase evaluation budget: the smaller of the move-count and +score-calculation limits minus the work already counted, or `None` when the +phase sets neither limit. A window never exceeds it, so fixed-work limits stop +at the same candidate as sequential evaluation. +*/ +pub(crate) fn remaining_evaluation_budget( + solver_scope: &SolverScope<'_, S, D, BestCb>, +) -> Option +where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, +{ + let stats = solver_scope.stats(); + let moves = solver_scope + .inphase_move_count_limit + .map(|limit| limit.saturating_sub(stats.moves_evaluated)); + let score_calculations = solver_scope + .inphase_score_calc_count_limit + .map(|limit| limit.saturating_sub(stats.score_calculations)); + match (moves, score_calculations) { + (Some(moves), Some(score_calculations)) => Some(moves.min(score_calculations)), + (moves, score_calculations) => moves.or(score_calculations), + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs new file mode 100644 index 00000000..34d391f3 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs @@ -0,0 +1,17 @@ +/* Parallel local-search evaluation. + +The main director and persistent evaluator replicas run trials for one window +of candidates at a time; the coordinator consumes the results in candidate +order through the unchanged acceptor and forager. +*/ + +mod budget; +mod pool; +mod window; + +#[cfg(test)] +mod tests; + +pub(crate) use budget::remaining_evaluation_budget; +pub(crate) use pool::EvaluatorPool; +pub(crate) use window::evaluate_window; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs new file mode 100644 index 00000000..f1a07a61 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -0,0 +1,139 @@ +use std::marker::PhantomData; +use std::time::Duration; + +use rayon::prelude::*; +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::Director; + +use crate::heuristic::r#move::Move; +use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; +use crate::phase::localsearch::evaluation::TrialOutcome; +use crate::scope::{ProgressCallback, SolverScope}; + +/* Candidates per parallel window. Fixed so the trajectory does not depend on +the number of evaluators. +*/ +pub(crate) const PARALLEL_EVALUATION_WINDOW: usize = 32; + +pub(super) type TrialOutputs = Vec<(usize, TrialOutcome, Duration)>; + +/* Evaluators for one local-search phase: the main director plus +`requested - 1` persistent replicas, kept at the committed working solution. +*/ +pub(crate) struct EvaluatorPool +where + S: PlanningSolution, + M: Move, +{ + requested: usize, + replicas: Vec>, + main_outputs: TrialOutputs, + synced_generation: Option, + _phantom: PhantomData (S, M)>, +} + +pub(super) struct EvaluatorReplica +where + S: PlanningSolution, + M: Move, +{ + pub(super) director: D, + pub(super) journal_pool: Vec>, + pub(super) outputs: TrialOutputs, +} + +impl EvaluatorPool +where + S: PlanningSolution, + D: Director, + M: Move, +{ + // `requested` counts evaluators, the main director included. + pub(crate) fn new(requested: usize) -> Self { + Self { + requested: requested.max(1), + replicas: Vec::new(), + main_outputs: Vec::new(), + synced_generation: None, + _phantom: PhantomData, + } + } + + pub(crate) fn is_parallel(&self) -> bool { + !self.replicas.is_empty() + } + + pub(crate) fn window_capacity(&self, remaining: Option) -> usize { + if !self.is_parallel() { + return 1; + } + let remaining = remaining.map_or(usize::MAX, |remaining| { + usize::try_from(remaining).unwrap_or(usize::MAX) + }); + PARALLEL_EVALUATION_WINDOW.min(remaining).max(1) + } + + /* Rebuilds the replicas when the working solution was written since they + last synced. Each replica clones the working solution once; this is the + evaluator replica clone boundary. + */ + pub(crate) fn sync(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>) + where + BestCb: ProgressCallback, + { + let generation = solver_scope.working_solution_generation(); + if self.requested <= 1 || self.synced_generation == Some(generation) { + return; + } + self.replicas.clear(); + for _ in 1..self.requested { + let director = solver_scope.score_director().fork_replica().expect( + "parallel evaluation requires a director built with a constraint-set constructor", + ); + self.replicas.push(EvaluatorReplica { + director, + journal_pool: Vec::new(), + outputs: Vec::new(), + }); + } + self.synced_generation = Some(generation); + } + + /* Applies the selected move to every replica by borrow, ahead of the + owned commit on the main director, and checks that each replica reaches + the committed step score. + */ + pub(crate) fn advance(&mut self, selected: &MoveCandidateRef<'_, S, M>, expected: S::Score) { + if !self.is_parallel() { + return; + } + self.replicas.par_iter_mut().for_each(|replica| { + let _undo = selected.do_move(&mut replica.director); + let score = replica.director.calculate_score(); + assert!( + score == expected, + "evaluator replica diverged from the committed step: replica {score}, step {expected}" + ); + }); + } + + pub(crate) fn mark_synced(&mut self, generation: u64) { + if self.is_parallel() { + self.synced_generation = Some(generation); + } + } + + pub(super) fn parts_mut( + &mut self, + ) -> ( + &mut TrialOutputs, + &mut [EvaluatorReplica], + ) { + (&mut self.main_outputs, &mut self.replicas) + } + + #[cfg(test)] + pub(super) fn replicas(&self) -> impl Iterator { + self.replicas.iter().map(|replica| &replica.director) + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs new file mode 100644 index 00000000..0d344a0a --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -0,0 +1,274 @@ +// Tests for parallel local-search evaluation. + +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::SoftScore; +use solverforge_core::{ConstraintRef, ImpactType}; +use solverforge_scoring::api::constraint_set::IncrementalConstraint; +use solverforge_scoring::stream::collection_extract::{source, ChangeSource}; +use solverforge_scoring::{ConstraintSet, IncrementalUniConstraint, ScoreDirector}; + +use super::EvaluatorPool; +use crate::heuristic::r#move::ChangeMove; +use crate::heuristic::selector::ChangeMoveSelector; +use crate::phase::localsearch::{ + solve_local_search_with_resources, AcceptedCountForager, BestScoreForager, + HillClimbingAcceptor, LocalSearchForager, SelectorCursorSource, +}; +use crate::scope::SolverScope; +use crate::test_utils::{ + create_nqueens_descriptor, get_queen_row, nqueens_clone_count, reset_nqueens_clone_count, + set_queen_row, NQueensSolution, Queen, +}; + +type QueenMove = ChangeMove; + +fn queen_weight(queen: &Queen) -> SoftScore { + match queen.row { + Some(row) => SoftScore::of((row * 3 + queen.column * 7) % 11), + None => SoftScore::of(20), + } +} + +fn queen_constraints( + _solution: &NQueensSolution, +) -> (impl IncrementalConstraint,) { + (IncrementalUniConstraint::new( + ConstraintRef::new("", "Queen placement"), + ImpactType::Penalty, + source( + (|s: &NQueensSolution| s.queens.as_slice()) as fn(&NQueensSolution) -> &[Queen], + ChangeSource::Descriptor(0), + ), + |_s: &NQueensSolution, _queen: &Queen| true, + queen_weight, + false, + ),) +} + +fn starting_solution() -> NQueensSolution { + NQueensSolution::with_rows(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8]) +} + +fn forkable_director( + solution: NQueensSolution, +) -> ScoreDirector> { + let constraints = queen_constraints(&solution); + ScoreDirector::with_descriptor( + solution, + constraints, + create_nqueens_descriptor(), + |s, _| s.queens.len(), + ) + .with_replica_constraints(queen_constraints) +} + +type QueenSelector = ChangeMoveSelector< + NQueensSolution, + i64, + crate::heuristic::selector::FromSolutionEntitySelector, + crate::heuristic::selector::StaticValueSelector, +>; + +fn selector() -> SelectorCursorSource { + SelectorCursorSource::new(ChangeMoveSelector::simple( + get_queen_row, + set_queen_row, + 0, + 0, + "row", + (0..10).collect(), + )) +} + +struct RunResult { + rows: Vec>, + score: Option, + moves_evaluated: u64, + moves_applied: u64, + moves_speculative: u64, + steps: u64, +} + +fn run( + evaluator_count: usize, + mut forager: Fo, + steps: u64, + move_limit: Option, +) -> RunResult +where + Fo: LocalSearchForager, +{ + let mut solver_scope = SolverScope::new(forkable_director(starting_solution())).with_seed(7); + solver_scope.start_solving(); + solver_scope.inphase_move_count_limit = move_limit; + let mut source = selector(); + let mut acceptor = HillClimbingAcceptor::new(); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(steps), + evaluator_count, + &mut solver_scope, + ); + let stats = solver_scope.stats(); + RunResult { + rows: solver_scope + .working_solution() + .queens + .iter() + .map(|queen| queen.row) + .collect(), + score: solver_scope.best_score().copied(), + moves_evaluated: stats.moves_evaluated, + moves_applied: stats.moves_applied, + moves_speculative: stats.moves_speculative, + steps: stats.step_count, + } +} + +#[test] +fn pool_with_one_evaluator_never_forks() { + let director = + ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { + s.queens.len() + }); + let mut solver_scope = SolverScope::new(director); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(1); + + pool.sync(&solver_scope); + + assert!(!pool.is_parallel()); + assert_eq!(pool.window_capacity(None), 1); +} + +#[test] +#[should_panic(expected = "constraint-set constructor")] +fn pool_requires_a_forkable_director() { + let director = + ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { + s.queens.len() + }); + let mut solver_scope = SolverScope::new(director); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(2); + + pool.sync(&solver_scope); +} + +#[test] +fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { + let mut solver_scope = SolverScope::new(forkable_director(starting_solution())); + solver_scope.start_solving(); + solver_scope.calculate_score(); + let mut pool = EvaluatorPool::::new(4); + + reset_nqueens_clone_count(); + pool.sync(&solver_scope); + assert_eq!(nqueens_clone_count(), 3); + assert_eq!(pool.replicas().count(), 3); + assert_eq!(pool.window_capacity(None), 32); + assert_eq!(pool.window_capacity(Some(5)), 5); + + pool.sync(&solver_scope); + assert_eq!( + nqueens_clone_count(), + 3, + "an unchanged generation must not fork again" + ); + + solver_scope.mutate(|director| { + director.before_variable_changed(0, 0); + director.working_solution_mut().queens[0].row = Some(7); + director.after_variable_changed(0, 0); + }); + let main_score = solver_scope.calculate_score(); + pool.sync(&solver_scope); + + assert_eq!(nqueens_clone_count(), 6); + for replica in pool.replicas() { + assert_eq!(replica.working_solution().queens[0].row, Some(7)); + assert_eq!(replica.working_solution().score(), Some(main_score)); + } +} + +#[test] +fn sequential_and_parallel_reach_identical_trajectories() { + let sequential = run(1, AcceptedCountForager::new(4, false), 25, None); + for evaluator_count in [2, 4] { + let parallel = run( + evaluator_count, + AcceptedCountForager::new(4, false), + 25, + None, + ); + assert_eq!(parallel.rows, sequential.rows); + assert_eq!(parallel.score, sequential.score); + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(parallel.moves_applied, sequential.moves_applied); + assert_eq!(parallel.steps, sequential.steps); + } + assert!(sequential.moves_applied > 0); +} + +#[test] +fn speculative_evaluations_are_reported_and_not_counted() { + let sequential = run(1, AcceptedCountForager::new(1, false), 10, None); + let parallel = run(2, AcceptedCountForager::new(1, false), 10, None); + + assert_eq!(parallel.rows, sequential.rows); + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(sequential.moves_speculative, 0); + assert!(parallel.moves_speculative > 0); +} + +#[test] +fn in_phase_move_limit_reserves_the_window() { + let parallel = run(2, BestScoreForager::new(false), 10, Some(5)); + + assert_eq!(parallel.moves_evaluated, 5); + assert_eq!(parallel.moves_speculative, 0); +} + +#[test] +fn replicas_follow_solution_replacement() { + let run_with_replacement = |evaluator_count: usize| { + let mut solver_scope = + SolverScope::new(forkable_director(starting_solution())).with_seed(11); + solver_scope.start_solving(); + let mut source = selector(); + let mut acceptor = HillClimbingAcceptor::new(); + let mut forager = AcceptedCountForager::new(3, false); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(5), + evaluator_count, + &mut solver_scope, + ); + solver_scope.replace_working_solution_and_reinitialize(NQueensSolution::with_rows(&[ + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + ])); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(5), + evaluator_count, + &mut solver_scope, + ); + solver_scope + .working_solution() + .queens + .iter() + .map(|queen| queen.row) + .collect::>() + }; + + assert_eq!(run_with_replacement(2), run_with_replacement(1)); +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs new file mode 100644 index 00000000..8cc1aba6 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs @@ -0,0 +1,86 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::Director; + +use crate::heuristic::r#move::Move; +use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; +use crate::phase::localsearch::evaluation::{run_trial, TrialOutcome}; + +use super::pool::{EvaluatorPool, TrialOutputs}; + +/* Runs the trial for every candidate in the window. The main director takes +its share inline on the coordinator thread; each replica takes its share in a +spawned task. Slots are handed out through one atomic counter, and results land +at their slot index, so which evaluator ran a slot never affects the outcome. +*/ +pub(crate) fn evaluate_window( + candidates: &[MoveCandidateRef<'_, S, M>], + main_director: &mut D, + main_journal_pool: &mut Vec>, + evaluators: &mut EvaluatorPool, + results: &mut Vec, Duration)>>, +) where + S: PlanningSolution, + D: Director, + M: Move, +{ + let next = AtomicUsize::new(0); + let (main_outputs, replicas) = evaluators.parts_mut(); + main_outputs.clear(); + rayon::scope(|scope| { + for replica in replicas.iter_mut() { + let next = &next; + replica.outputs.clear(); + scope.spawn(move |_| { + drain_window( + candidates, + next, + &mut replica.director, + &mut replica.journal_pool, + &mut replica.outputs, + ); + }); + } + drain_window( + candidates, + &next, + main_director, + main_journal_pool, + main_outputs, + ); + }); + results.clear(); + results.resize_with(candidates.len(), || None); + for (index, outcome, elapsed) in main_outputs.drain(..) { + results[index] = Some((outcome, elapsed)); + } + for replica in replicas.iter_mut() { + for (index, outcome, elapsed) in replica.outputs.drain(..) { + results[index] = Some((outcome, elapsed)); + } + } +} + +fn drain_window( + candidates: &[MoveCandidateRef<'_, S, M>], + next: &AtomicUsize, + director: &mut D, + journal_pool: &mut Vec>, + outputs: &mut TrialOutputs, +) where + S: PlanningSolution, + D: Director, + M: Move, +{ + loop { + let index = next.fetch_add(1, Ordering::Relaxed); + let Some(candidate) = candidates.get(index) else { + break; + }; + let started = Instant::now(); + let outcome = run_trial(candidate, director, journal_pool); + outputs.push((index, outcome, started.elapsed())); + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/phase.rs b/crates/solverforge-solver/src/phase/localsearch/phase.rs index 61c7e3fa..cd6cb12d 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase.rs @@ -10,6 +10,7 @@ use tracing::info; use crate::heuristic::r#move::Move; use crate::heuristic::selector::move_selector::{CandidateId, MoveCandidateRef}; +use crate::phase::localsearch::parallel::EvaluatorPool; use crate::phase::localsearch::{ Acceptor, LocalSearchForager, MoveCursorSource, SelectorCursorSource, }; @@ -241,6 +242,7 @@ pub(crate) fn solve_local_search_with_resources( acceptor: &mut A, forager: &mut Fo, step_limit: Option, + evaluator_count: usize, solver_scope: &mut SolverScope, ) where S: PlanningSolution, @@ -263,6 +265,7 @@ pub(crate) fn solve_local_search_with_resources( ); acceptor.phase_started(&last_step_score); + let mut evaluators = EvaluatorPool::::new(evaluator_count); let start_time = Instant::now(); loop { if phase_scope.solver_scope_mut().should_terminate() { @@ -279,6 +282,7 @@ pub(crate) fn solve_local_search_with_resources( forager, &mut phase_scope, &mut last_step_score, + &mut evaluators, ) { StepOutcome::Continue | StepOutcome::Restart => continue, StepOutcome::Terminate => break, @@ -345,6 +349,7 @@ where acceptor, forager, *step_limit, + 1, solver_scope, ); } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 6d3a7b57..777447dc 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -11,11 +11,17 @@ use crate::phase::control::{ should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, }; use crate::phase::localsearch::evaluation::run_trial; +use crate::phase::localsearch::parallel::{ + evaluate_window, remaining_evaluation_budget, EvaluatorPool, +}; use crate::phase::localsearch::{Acceptor, LocalSearchForager}; use crate::scope::{ProgressCallback, StepScope}; -use crate::stats::{CandidateTraceDisposition, CandidateTracePullToken}; +use crate::stats::CandidateTracePullToken; -use super::decision::{consume_candidate, record_generated_candidate}; +use super::decision::{ + consume_candidate, discard_window_from, interrupt_window_at, record_generated_candidate, + WindowSlot, +}; use super::StepMoveLabelCounts; pub(super) struct CandidateLoopState { @@ -49,6 +55,7 @@ pub(super) fn evaluate_candidates( acceptor: &mut A, forager: &mut Fo, requires_move_signatures: bool, + evaluators: &mut EvaluatorPool, mut state: CandidateLoopState, ) -> CandidateLoopState where @@ -63,81 +70,142 @@ where // The control poll reads the wall clock; forwarding every candidate // iteration makes it measurable at scale (measured ~9% of wall before // batching). The step loop therefore polls once per batch window; a - // stop stays latched, and per-evaluation interruption still runs on - // its every-64-evaluations schedule below. + // stop stays latched, and per-evaluation interruption still runs before + // every consumed candidate. // Retired undo journals are recycled across the step's trials so // arbitrary-long journals do not grow the heap per trial. + // + // Each pass produces a window of candidates serially on the main + // director, evaluates it (inline for one evaluator, across the evaluator + // pool otherwise), and consumes the results in candidate order. With a + // window of one, the checks, pulls and decisions are exactly the + // sequential loop's. let mut journal_pool: Vec> = Vec::new(); let mut control_polls = 0usize; let mut control_latched = false; + let mut window: Vec = Vec::new(); + let mut results = Vec::new(); while !forager.is_quit_early() { if state.interrupted { break; } - control_polls += 1; - if !control_latched && control_polls.is_multiple_of(CONTROL_POLL_INTERVAL) { - control_latched = should_interrupt_before_candidate(step_scope) - || step_scope - .phase_scope_mut() - .solver_scope_mut() - .should_terminate(); + + // Production: serial, canonical order, main director. + let capacity = evaluators.window_capacity(remaining_evaluation_budget( + step_scope.phase_scope().solver_scope(), + )); + window.clear(); + let mut exhausted = false; + while window.len() < capacity { + control_polls += 1; + if !control_latched && control_polls.is_multiple_of(CONTROL_POLL_INTERVAL) { + control_latched = should_interrupt_before_candidate(step_scope) + || step_scope + .phase_scope_mut() + .solver_scope_mut() + .should_terminate(); + } + if control_latched { + state.interrupted = true; + break; + } + + let generation_started = std::time::Instant::now(); + let Some(candidate_id) = + cursor.next_candidate_with_resources(step_scope.score_director_mut(), resources) + else { + exhausted = true; + break; + }; + window.push(record_generated_candidate( + &*cursor, + candidate_id, + generation_started, + step_scope, + &mut state, + )); } - if control_latched { - state.interrupted = true; + if window.is_empty() { break; } - let generation_started = std::time::Instant::now(); - let Some(candidate_id) = - cursor.next_candidate_with_resources(step_scope.score_director_mut(), resources) - else { - break; - }; - let slot = record_generated_candidate( - &*cursor, - candidate_id, - generation_started, - step_scope, - &mut state, - ); - - if should_interrupt_before_evaluation(step_scope) + // Dispatch: the sequential before-evaluation check for the first slot. + if state.interrupted + || should_interrupt_before_evaluation(step_scope) || step_scope .phase_scope_mut() .solver_scope_mut() .should_terminate() { - if let Some(token) = slot.trace_token { - step_scope - .phase_scope_mut() - .record_candidate_trace_disposition( - token, - CandidateTraceDisposition::InterruptedBeforeEvaluation, - ); - } + interrupt_window_at(cursor, step_scope, &window, 0, false); state.interrupted = true; break; } - let evaluation_started = std::time::Instant::now(); - let outcome = { + // Evaluation. + if evaluators.is_parallel() { + let candidates: Vec<_> = window + .iter() + .map(|slot| { + cursor + .candidate(slot.id) + .expect("window candidate must remain borrowable") + }) + .collect(); + evaluate_window( + &candidates, + step_scope.score_director_mut(), + &mut journal_pool, + evaluators, + &mut results, + ); + } else { let mov = cursor - .candidate(slot.id) + .candidate(window[0].id) .expect("discovered candidate id must remain borrowable"); - run_trial(&mov, step_scope.score_director_mut(), &mut journal_pool) - }; - consume_candidate( - cursor, - step_scope, - &slot, - outcome, - evaluation_started.elapsed(), - last_step_score, - acceptor, - forager, - requires_move_signatures, - &mut state, - ); + let started = std::time::Instant::now(); + let outcome = run_trial(&mov, step_scope.score_director_mut(), &mut journal_pool); + results.clear(); + results.push(Some((outcome, started.elapsed()))); + } + + // Consumption in candidate order. + for index in 0..window.len() { + if index > 0 { + if forager.is_quit_early() { + discard_window_from(cursor, step_scope, &window, index, true); + break; + } + if should_interrupt_before_evaluation(step_scope) + || step_scope + .phase_scope_mut() + .solver_scope_mut() + .should_terminate() + { + interrupt_window_at(cursor, step_scope, &window, index, true); + state.interrupted = true; + break; + } + } + let (outcome, elapsed) = results[index] + .take() + .expect("every window slot has a trial result"); + consume_candidate( + cursor, + step_scope, + &window[index], + outcome, + elapsed, + last_step_score, + acceptor, + forager, + requires_move_signatures, + &mut state, + ); + } + if state.interrupted || exhausted { + break; + } } state diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs index c27e5263..e1da3dc6 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -265,6 +265,70 @@ pub(super) fn consume_candidate( } } +/// Ends the window at `index` because of an interruption: slot `index` gets +/// the sequential loop's `InterruptedBeforeEvaluation` record and stays live, +/// exactly as the sequential loop leaves it; later slots are discarded. +/// `evaluated` tells whether the window's trials already ran. +pub(super) fn interrupt_window_at( + cursor: &mut C, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + window: &[WindowSlot], + index: usize, + evaluated: bool, +) where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + C: ResourceMoveCursor, +{ + let slot = &window[index]; + if let Some(token) = slot.trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::InterruptedBeforeEvaluation, + ); + } + if evaluated { + step_scope.phase_scope_mut().record_move_speculative(); + } + discard_window_from(cursor, step_scope, window, index + 1, evaluated); +} + +/// Discards slots `from..` of a window that the step will not consume: +/// records `DiscardedSpeculative`, releases them, and counts them as +/// speculative work when their trials already ran. +pub(super) fn discard_window_from( + cursor: &mut C, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + window: &[WindowSlot], + from: usize, + evaluated: bool, +) where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + C: ResourceMoveCursor, +{ + for slot in window.iter().skip(from) { + if let Some(token) = slot.trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::DiscardedSpeculative, + ); + } + if evaluated { + step_scope.phase_scope_mut().record_move_speculative(); + } + assert!(cursor.release_candidate(slot.id)); + } +} + fn record_rejected_trace( step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, trace_token: Option, diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs index 2d579ca2..08d1e555 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs @@ -13,6 +13,7 @@ use crate::phase::control::{ settle_search_interrupt, should_interrupt_after_step, should_interrupt_before_candidate, StepInterrupt, }; +use crate::phase::localsearch::parallel::EvaluatorPool; use crate::phase::localsearch::{Acceptor, LocalSearchForager, MoveCursorSource}; use crate::scope::{PendingControl, PhaseScope, ProgressCallback, StepScope}; use crate::stats::{AppliedMoveTelemetry, CandidateTraceDisposition}; @@ -34,6 +35,7 @@ pub(super) fn execute_step( forager: &mut Fo, phase_scope: &mut PhaseScope<'_, '_, S, D, BestCb>, last_step_score: &mut S::Score, + evaluators: &mut EvaluatorPool, ) -> StepOutcome where S: PlanningSolution, @@ -45,6 +47,7 @@ where BestCb: ProgressCallback, { let mut step_scope = StepScope::new(phase_scope); + evaluators.sync(step_scope.phase_scope().solver_scope()); /* Reset forager and acceptor for this step. Pass best and last-step scores so foragers that implement @@ -91,6 +94,7 @@ where acceptor, forager, requires_move_signatures, + evaluators, CandidateLoopState::new(interrupted), ); @@ -139,9 +143,16 @@ where }; } let previous_score = *last_step_score; + evaluators.advance(&selected_move, selected_score); step_scope.apply_committed_change(|score_director| { cursor.apply_owned_candidate(selected_index, score_director); }); + evaluators.mark_synced( + step_scope + .phase_scope() + .solver_scope() + .working_solution_generation(), + ); if let Some(token) = selected_trace_token { step_scope .phase_scope_mut() diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs index c8798514..78d153e7 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs @@ -94,6 +94,7 @@ where &mut acceptor, &mut forager, None, + 1, solver_scope, ); }); diff --git a/crates/solverforge-solver/src/scope/solver/scope_core.rs b/crates/solverforge-solver/src/scope/solver/scope_core.rs index dcba0e4e..f1017784 100644 --- a/crates/solverforge-solver/src/scope/solver/scope_core.rs +++ b/crates/solverforge-solver/src/scope/solver/scope_core.rs @@ -98,6 +98,11 @@ pub struct SolverScope<'t, S: PlanningSolution, D: Director, ProgressCb = ()> last_best_elapsed: Option, best_solution_revision: Option, solution_revision: u64, + /* Monotonic count of working-solution writes: every committed mutation + and every wholesale replacement. Unlike `solution_revision`, it never + resets, so evaluator replicas can detect any write since they synced. + */ + working_solution_generation: u64, construction_frontier: ConstructionFrontier, phase_budget: Option<&'t PhaseBudget>, pub inphase_step_count_limit: Option, @@ -377,6 +382,7 @@ impl<'t, S: PlanningSolution, D: Director> SolverScope<'t, S, D, ()> { last_best_elapsed: None, best_solution_revision: None, solution_revision: 1, + working_solution_generation: 0, construction_frontier, phase_budget: None, inphase_step_count_limit: None, @@ -422,6 +428,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> last_best_elapsed: None, best_solution_revision: None, solution_revision: 1, + working_solution_generation: 0, construction_frontier, phase_budget: None, inphase_step_count_limit: None, @@ -586,6 +593,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> last_best_elapsed: self.last_best_elapsed, best_solution_revision: self.best_solution_revision, solution_revision: self.solution_revision, + working_solution_generation: self.working_solution_generation, construction_frontier: self.construction_frontier, phase_budget: self.phase_budget, inphase_step_count_limit: self.inphase_step_count_limit, @@ -675,6 +683,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn replace_working_solution_and_reinitialize(&mut self, solution: S) -> S::Score { + self.working_solution_generation += 1; *self.score_director.working_solution_mut() = solution; self.score_director.reset(); self.current_score = None; @@ -731,6 +740,10 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> self.solution_revision } + pub(crate) fn working_solution_generation(&self) -> u64 { + self.working_solution_generation + } + pub(crate) fn apply_committed_move(&mut self, mov: &M) where M: Move, diff --git a/crates/solverforge-solver/src/scope/solver/scope_progress.rs b/crates/solverforge-solver/src/scope/solver/scope_progress.rs index da1308cb..1df34a93 100644 --- a/crates/solverforge-solver/src/scope/solver/scope_progress.rs +++ b/crates/solverforge-solver/src/scope/solver/scope_progress.rs @@ -610,6 +610,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> self.current_score = None; let output = mutate(&mut self.score_director); self.advance_solution_revision(); + self.working_solution_generation += 1; output } } diff --git a/crates/solverforge-solver/src/scope/tests.rs b/crates/solverforge-solver/src/scope/tests.rs index afadb72c..9157d934 100644 --- a/crates/solverforge-solver/src/scope/tests.rs +++ b/crates/solverforge-solver/src/scope/tests.rs @@ -429,3 +429,36 @@ fn solver_scope_forwards_candidate_trace_plan_finalization() { assert_eq!(trace.header.resolved_phase_plan, terminal_plan); assert!(trace.header.resolved_phase_plan_complete); } + +#[test] +fn working_solution_generation_is_monotonic_across_commits_and_replacement() { + let descriptor = SolutionDescriptor::new("TieSolution", TypeId::of::()); + let director = ScoreDirector::simple( + TieSolution { + marker: 0, + score: None, + }, + descriptor, + |_solution, _descriptor_index| 0, + ); + let mut scope = SolverScope::new(director); + scope.start_solving(); + let initial = scope.working_solution_generation(); + + scope.mutate(|score_director| { + score_director.working_solution_mut().marker = 3; + }); + scope.apply_committed_change(|score_director| { + score_director.working_solution_mut().marker = 4; + }); + let after_commits = scope.working_solution_generation(); + assert_eq!(after_commits, initial + 2); + + scope.replace_working_solution_and_reinitialize(TieSolution { + marker: 9, + score: None, + }); + + assert_eq!(scope.solution_revision(), 1); + assert_eq!(scope.working_solution_generation(), after_commits + 1); +} From 6f882a63696c44125c491fbb70f6e6fb8c296abb Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:57:36 +0200 Subject: [PATCH 07/30] feat(solver): drive acceptor-forager evaluation from move_thread_count 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. --- crates/solverforge-config/WIREFRAME.md | 13 +++++++++++++ crates/solverforge-config/src/tests/parsing.rs | 17 +++++++++++++++++ crates/solverforge-solver/WIREFRAME.md | 1 + .../src/phase/localsearch/mod.rs | 1 + .../src/phase/localsearch/parallel/config.rs | 12 ++++++++++++ .../src/phase/localsearch/parallel/mod.rs | 2 ++ .../src/phase/localsearch/parallel/tests.rs | 16 ++++++++++++++++ .../compiler/executor/runner/local_search.rs | 4 +++- .../compiler/executor/runner/lowering.rs | 5 +++++ 9 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/config.rs diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 44af1d6a..311c42d8 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -720,6 +720,19 @@ Derives: `Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize`. | `None` | Single-threaded | | `Count(usize)` | Explicit count | +TOML: `move_thread_count = "auto"`, `"none"`, or `{ count = 4 }`. + +`SolverConfig::move_thread_count` drives parallel evaluation in the compiled +runtime's acceptor-forager local search; `PartitionedSearchConfig::thread_count` +sizes partitioned search separately. + +| `move_thread_count` | Acceptor-forager local search | +|---|---| +| `None` | sequential | +| `Auto` | sequential | +| `Count(0)`, `Count(1)` | sequential | +| `Count(n >= 2)` | `min(n, rayon::current_num_threads())` evaluators: the main director plus that many minus one replicas | + ### `PhaseConfig` Derives: `Debug, Clone, Deserialize, Serialize`. Tagged `#[serde(tag = "type", rename_all = "snake_case")]`. diff --git a/crates/solverforge-config/src/tests/parsing.rs b/crates/solverforge-config/src/tests/parsing.rs index 6d97c498..764d81e5 100644 --- a/crates/solverforge-config/src/tests/parsing.rs +++ b/crates/solverforge-config/src/tests/parsing.rs @@ -248,3 +248,20 @@ fn grouped_scalar_move_selector_parses_and_roundtrips() { assert_eq!(selector.max_moves_per_step, Some(64)); assert!(selector.require_hard_improvement); } + +#[test] +fn move_thread_count_parses_every_documented_form() { + let parse = |value: &str| { + SolverConfig::from_toml_str(&format!("move_thread_count = {value}")) + .unwrap() + .move_thread_count + }; + + assert_eq!(parse("\"auto\""), MoveThreadCount::Auto); + assert_eq!(parse("\"none\""), MoveThreadCount::None); + assert_eq!(parse("{ count = 4 }"), MoveThreadCount::Count(4)); + assert_eq!( + SolverConfig::from_toml_str("").unwrap().move_thread_count, + MoveThreadCount::Auto + ); +} diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index ceb2126c..cf5c4ee8 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -278,6 +278,7 @@ src/ │ │ ├── forager/tests.rs — Tests │ │ ├── parallel/mod.rs — Parallel evaluation module declarations │ │ ├── parallel/budget.rs — Remaining in-phase evaluation budget for window sizing +│ │ ├── parallel/config.rs — move_thread_count resolution to an evaluator count │ │ ├── parallel/pool.rs — EvaluatorPool: evaluator replicas, sync, and post-commit advance │ │ ├── parallel/window.rs — Window evaluation across the main director and replicas │ │ ├── parallel/tests.rs — Tests diff --git a/crates/solverforge-solver/src/phase/localsearch/mod.rs b/crates/solverforge-solver/src/phase/localsearch/mod.rs index db34c473..e798f0ef 100644 --- a/crates/solverforge-solver/src/phase/localsearch/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/mod.rs @@ -25,5 +25,6 @@ pub use forager::{ AcceptedCountForager, BestScoreForager, FirstAcceptedForager, FirstBestScoreImprovingForager, FirstLastStepScoreImprovingForager, ForagerDecision, LocalSearchForager, }; +pub(crate) use parallel::resolve_evaluator_count; pub(crate) use phase::solve_local_search_with_resources; pub use phase::LocalSearchPhase; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs new file mode 100644 index 00000000..6de83879 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs @@ -0,0 +1,12 @@ +use solverforge_config::MoveThreadCount; + +/* Number of evaluators (main director included) for acceptor-forager local +search. `Auto` stays sequential until benchmarks set its policy; an explicit +count is capped by the threads of the pool the solve runs on. +*/ +pub(crate) fn resolve_evaluator_count(count: &MoveThreadCount) -> usize { + match count { + MoveThreadCount::Auto | MoveThreadCount::None => 1, + MoveThreadCount::Count(count) => (*count).clamp(1, rayon::current_num_threads().max(1)), + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs index 34d391f3..524e9ac3 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs @@ -6,6 +6,7 @@ order through the unchanged acceptor and forager. */ mod budget; +mod config; mod pool; mod window; @@ -13,5 +14,6 @@ mod window; mod tests; pub(crate) use budget::remaining_evaluation_budget; +pub(crate) use config::resolve_evaluator_count; pub(crate) use pool::EvaluatorPool; pub(crate) use window::evaluate_window; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs index 0d344a0a..338e404a 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -272,3 +272,19 @@ fn replicas_follow_solution_replacement() { assert_eq!(run_with_replacement(2), run_with_replacement(1)); } + +#[test] +fn evaluator_count_resolution() { + use solverforge_config::MoveThreadCount; + + use super::resolve_evaluator_count; + + assert_eq!(resolve_evaluator_count(&MoveThreadCount::None), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Auto), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(0)), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(1)), 1); + assert_eq!( + resolve_evaluator_count(&MoveThreadCount::Count(2)), + 2.min(rayon::current_num_threads()) + ); +} diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs index 78d153e7..c59b9408 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/local_search.rs @@ -46,6 +46,7 @@ where forager: Option, score_tie_break: ScoreTieBreak, random_seed: Option, + evaluator_count: usize, }, VariableNeighborhoodDescent { phase_termination: Option, @@ -78,6 +79,7 @@ where forager, score_tie_break, random_seed, + evaluator_count, } => { let (mut acceptor, mut forager) = build_acceptor_and_forager::( *components, @@ -94,7 +96,7 @@ where &mut acceptor, &mut forager, None, - 1, + *evaluator_count, solver_scope, ); }); diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs index c07e2908..fcb86c73 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/runner/lowering.rs @@ -11,6 +11,7 @@ use solverforge_core::domain::PlanningSolution; use solverforge_core::score::{ParseableScore, Score}; use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter; +use crate::phase::localsearch::resolve_evaluator_count; use crate::runtime::compiler::{ CompiledAcceptorForagerSelector, CompiledLocalSearch, CompiledRuntimeExtension, CompiledRuntimePhase, DefaultLocalSearchEligibility, DefaultRuntimeBindings, @@ -87,6 +88,9 @@ where forager: None, score_tie_break: solverforge_config::ScoreTieBreak::Random, random_seed: solver_config.random_seed, + evaluator_count: resolve_evaluator_count( + &solver_config.move_thread_count, + ), }), Some(plan.candidate_trace_plan()), ) @@ -168,6 +172,7 @@ where forager: config.forager, score_tie_break: config.score_tie_break, random_seed: solver_config.random_seed, + evaluator_count: resolve_evaluator_count(&solver_config.move_thread_count), }, declaration, )) From f05fc15c20fe441639de32e99853a61bf1b77971 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:58:40 +0200 Subject: [PATCH 08/30] test(solverforge): parallel local-search parity through the generated 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. --- .../tests/parallel_local_search.rs | 90 +++++++++++++++++++ .../tests/parallel_local_search/domain/mod.rs | 11 +++ .../parallel_local_search/domain/plan.rs | 62 +++++++++++++ .../parallel_local_search/domain/resource.rs | 7 ++ .../parallel_local_search/domain/task.rs | 12 +++ 5 files changed, 182 insertions(+) create mode 100644 crates/solverforge/tests/parallel_local_search.rs create mode 100644 crates/solverforge/tests/parallel_local_search/domain/mod.rs create mode 100644 crates/solverforge/tests/parallel_local_search/domain/plan.rs create mode 100644 crates/solverforge/tests/parallel_local_search/domain/resource.rs create mode 100644 crates/solverforge/tests/parallel_local_search/domain/task.rs diff --git a/crates/solverforge/tests/parallel_local_search.rs b/crates/solverforge/tests/parallel_local_search.rs new file mode 100644 index 00000000..edb59255 --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search.rs @@ -0,0 +1,90 @@ +use solverforge::{SolverEvent, SolverManager, SolverTelemetry, SolverTerminalReason}; + +#[path = "parallel_local_search/domain/mod.rs"] +mod domain; + +use domain::{Plan, Resource, Task}; + +fn seeded_plan(move_threads: usize) -> Plan { + let resources = (0..6) + .map(|idx| Resource { + id: format!("resource-{idx}"), + }) + .collect(); + let tasks = (0..40) + .map(|idx| Task { + id: format!("task-{idx}"), + weight: (idx * 7 + 3) % 11, + resource_idx: None, + }) + .collect(); + + Plan { + resources, + tasks, + score: None, + move_threads, + } +} + +fn solve(manager: &'static SolverManager, move_threads: usize) -> (Plan, SolverTelemetry) { + let (job_id, mut receiver) = manager + .solve(seeded_plan(move_threads)) + .expect("job should start"); + let mut completed = None; + while let Some(event) = receiver.blocking_recv() { + if let SolverEvent::Completed { metadata, solution } = event { + assert_eq!( + metadata.terminal_reason, + Some(SolverTerminalReason::Completed) + ); + completed = Some((solution, metadata.telemetry)); + break; + } + } + manager.delete(job_id).expect("delete completed job"); + completed.expect("expected a completed solution") +} + +#[test] +fn fixed_work_trajectory_is_identical_across_thread_counts() { + static MANAGER: SolverManager = SolverManager::new(); + + let runs: Vec<_> = [1, 2, 4] + .into_iter() + .map(|move_threads| solve(&MANAGER, move_threads)) + .collect(); + let (baseline, baseline_telemetry) = &runs[0]; + let baseline_assignment: Vec<_> = baseline + .tasks + .iter() + .map(|task| task.resource_idx) + .collect(); + assert!(baseline_telemetry.moves_applied > 0); + + for (solution, telemetry) in &runs[1..] { + let assignment: Vec<_> = solution + .tasks + .iter() + .map(|task| task.resource_idx) + .collect(); + assert_eq!(assignment, baseline_assignment); + assert_eq!(solution.score, baseline.score); + assert_eq!(telemetry.step_count, baseline_telemetry.step_count); + assert_eq!( + telemetry.moves_evaluated, + baseline_telemetry.moves_evaluated + ); + assert_eq!(telemetry.moves_applied, baseline_telemetry.moves_applied); + } +} + +#[test] +fn sequential_resolution_reports_no_speculation() { + static MANAGER: SolverManager = SolverManager::new(); + + for move_threads in [0, 1] { + let (_, telemetry) = solve(&MANAGER, move_threads); + assert_eq!(telemetry.moves_speculative, 0); + } +} diff --git a/crates/solverforge/tests/parallel_local_search/domain/mod.rs b/crates/solverforge/tests/parallel_local_search/domain/mod.rs new file mode 100644 index 00000000..7151628c --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search/domain/mod.rs @@ -0,0 +1,11 @@ +solverforge::planning_model! { + root = "crates/solverforge/tests/parallel_local_search/domain"; + + mod plan; + mod resource; + mod task; + + pub use plan::Plan; + pub use resource::Resource; + pub use task::Task; +} diff --git a/crates/solverforge/tests/parallel_local_search/domain/plan.rs b/crates/solverforge/tests/parallel_local_search/domain/plan.rs new file mode 100644 index 00000000..2c66c43c --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search/domain/plan.rs @@ -0,0 +1,62 @@ +use solverforge::prelude::*; +use solverforge::stream::ConstraintFactory; +use solverforge::{MoveThreadCount, SolverConfig}; + +use super::{Resource, Task}; + +#[planning_solution(constraints = "define_constraints", config = "parallel_config")] +pub struct Plan { + #[problem_fact_collection] + pub resources: Vec, + + #[planning_entity_collection] + pub tasks: Vec, + + #[planning_score] + pub score: Option, + + pub move_threads: usize, +} + +fn define_constraints() -> impl ConstraintSet { + ( + ConstraintFactory::::new() + .for_each(Plan::tasks()) + .filter(|task: &Task| task.resource_idx.is_none()) + .penalize(HardSoftScore::of(1, 0)) + .named("unassigned task"), + ConstraintFactory::::new() + .for_each(Plan::tasks()) + .filter(|task: &Task| task.resource_idx.is_some()) + .penalize(|task: &Task| { + let resource = task.resource_idx.unwrap_or_default() as i64; + HardSoftScore::of(0, (task.weight * (resource + 3)) % 17) + }) + .named("resource cost"), + ) +} + +const PARALLEL_SOLVER_TOML: &str = r#" +random_seed = 7 + +[[phases]] +type = "construction_heuristic" +construction_heuristic_type = "first_fit" + +[[phases]] +type = "local_search" + +[phases.termination] +step_count_limit = 150 +"#; + +fn parallel_config(solution: &Plan, _base: SolverConfig) -> SolverConfig { + let mut config = SolverConfig::from_toml_str(PARALLEL_SOLVER_TOML) + .expect("parallel local-search fixture config must parse"); + config.move_thread_count = if solution.move_threads == 0 { + MoveThreadCount::Auto + } else { + MoveThreadCount::Count(solution.move_threads) + }; + config +} diff --git a/crates/solverforge/tests/parallel_local_search/domain/resource.rs b/crates/solverforge/tests/parallel_local_search/domain/resource.rs new file mode 100644 index 00000000..9885d81b --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search/domain/resource.rs @@ -0,0 +1,7 @@ +use solverforge::prelude::*; + +#[problem_fact] +pub struct Resource { + #[planning_id] + pub id: String, +} diff --git a/crates/solverforge/tests/parallel_local_search/domain/task.rs b/crates/solverforge/tests/parallel_local_search/domain/task.rs new file mode 100644 index 00000000..d7d81e01 --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search/domain/task.rs @@ -0,0 +1,12 @@ +use solverforge::prelude::*; + +#[planning_entity] +pub struct Task { + #[planning_id] + pub id: String, + + pub weight: i64, + + #[planning_variable(value_range_provider = "resources", allows_unassigned = true)] + pub resource_idx: Option, +} From e7a296f0433819cadea9522f754ec200aa50950d Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 19:59:17 +0200 Subject: [PATCH 09/30] docs: describe parallel local-search evaluation 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. --- README.md | 7 +++++++ assets/solverforge-architecture.svg | 1 + docs/architecture.html | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/README.md b/README.md index f176bbee..3f5be0c2 100644 --- a/README.md +++ b/README.md @@ -741,6 +741,13 @@ SolverForge leverages Rust's zero-cost abstractions: - **Cache-friendly**: Contiguous memory layouts for hot paths - **Concrete native execution**: Score directors, deciders, bounders, move carriers, and selector carriers remain monomorphized; documented scorer-agnostic `&dyn Director` callbacks are narrow intentional seams +Local search can evaluate candidates on several cores within one solve: +`move_thread_count = { count = 4 }` in `solver.toml` (Python configs through +solverforge-py use `{"move_thread_count": {"count": 4}}`) runs each step's +trials on up to four evaluators while one coordinator keeps the same +acceptance order. Results are reproducible at fixed work, and the default +(`auto`) currently stays sequential. + Absolute throughput depends on the model, constraints, neighborhood, hardware, and termination policy. Use emitted generated/evaluated throughput for one run and the controlled paired benchmark gate below for regression decisions. diff --git a/assets/solverforge-architecture.svg b/assets/solverforge-architecture.svg index 4e97e178..3ded0f32 100644 --- a/assets/solverforge-architecture.svg +++ b/assets/solverforge-architecture.svg @@ -176,6 +176,7 @@ PartitionedSearch — rayon work split ExhaustiveSearch · CustomSearchPhase phases ordered by SolverConfig +LocalSearch — rayon window evaluation on replicas diff --git a/docs/architecture.html b/docs/architecture.html index 853a09b6..746b3b90 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -238,6 +238,7 @@ + @@ -505,6 +506,7 @@ + @@ -772,6 +774,7 @@ + @@ -1039,6 +1042,7 @@ PartitionedSearch — rayon work split ExhaustiveSearch · CustomSearchPhase phases ordered by SolverConfig +LocalSearch — rayon window evaluation on replicas @@ -1306,6 +1310,7 @@ + From 0fc802017bdb5ef83d8913432eab42840411277b Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 20:49:39 +0200 Subject: [PATCH 10/30] perf(solver): reuse local-search window buffers across steps 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. --- .../src/phase/localsearch/parallel/pool.rs | 29 +++++++++++++++++++ .../src/phase/localsearch/phase.rs | 2 ++ .../src/phase/localsearch/phase/candidates.rs | 5 ++-- .../src/phase/localsearch/phase/decision.rs | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index f1a07a61..5a8184a2 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -8,6 +8,7 @@ use solverforge_scoring::Director; use crate::heuristic::r#move::Move; use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; use crate::phase::localsearch::evaluation::TrialOutcome; +use crate::phase::localsearch::phase::WindowSlot; use crate::scope::{ProgressCallback, SolverScope}; /* Candidates per parallel window. Fixed so the trajectory does not depend on @@ -17,6 +18,9 @@ pub(crate) const PARALLEL_EVALUATION_WINDOW: usize = 32; pub(super) type TrialOutputs = Vec<(usize, TrialOutcome, Duration)>; +/// Trial results of one window, indexed by window slot. +pub(crate) type WindowResults = Vec, Duration)>>; + /* Evaluators for one local-search phase: the main director plus `requested - 1` persistent replicas, kept at the committed working solution. */ @@ -28,6 +32,9 @@ where requested: usize, replicas: Vec>, main_outputs: TrialOutputs, + // Window buffers lent to each step, so steps reuse their capacity. + window_slots: Vec, + window_results: WindowResults, synced_generation: Option, _phantom: PhantomData (S, M)>, } @@ -54,11 +61,33 @@ where requested: requested.max(1), replicas: Vec::new(), main_outputs: Vec::new(), + window_slots: Vec::new(), + window_results: Vec::new(), synced_generation: None, _phantom: PhantomData, } } + /// Lends the window buffers to one step; return them with + /// `restore_window_buffers`. + pub(crate) fn take_window_buffers(&mut self) -> (Vec, WindowResults) { + ( + std::mem::take(&mut self.window_slots), + std::mem::take(&mut self.window_results), + ) + } + + pub(crate) fn restore_window_buffers( + &mut self, + mut slots: Vec, + mut results: WindowResults, + ) { + slots.clear(); + results.clear(); + self.window_slots = slots; + self.window_results = results; + } + pub(crate) fn is_parallel(&self) -> bool { !self.replicas.is_empty() } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase.rs b/crates/solverforge-solver/src/phase/localsearch/phase.rs index cd6cb12d..b2b25e17 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase.rs @@ -22,6 +22,8 @@ mod candidates; mod decision; mod step; +pub(in crate::phase::localsearch) use decision::WindowSlot; + use step::{execute_step, StepOutcome}; const STEP_ACCEPTED_LABEL_LIMIT: usize = 32; diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 777447dc..7a0d5cb0 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -20,7 +20,6 @@ use crate::stats::CandidateTracePullToken; use super::decision::{ consume_candidate, discard_window_from, interrupt_window_at, record_generated_candidate, - WindowSlot, }; use super::StepMoveLabelCounts; @@ -83,8 +82,7 @@ where let mut journal_pool: Vec> = Vec::new(); let mut control_polls = 0usize; let mut control_latched = false; - let mut window: Vec = Vec::new(); - let mut results = Vec::new(); + let (mut window, mut results) = evaluators.take_window_buffers(); while !forager.is_quit_early() { if state.interrupted { break; @@ -208,5 +206,6 @@ where } } + evaluators.restore_window_buffers(window, results); state } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs index e1da3dc6..b26a01c1 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -23,7 +23,7 @@ use super::{candidate_selector_label, take_trace_token}; const PROGRESS_POLL_INTERVAL: u64 = 256; /// A generated candidate waiting for its trial result. -pub(super) struct WindowSlot { +pub(in crate::phase::localsearch) struct WindowSlot { pub(super) id: CandidateId, pub(super) selector_index: Option, pub(super) move_label: &'static str, From 6add550f837d40df93c3155a878142d7fc461fed Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 20:59:19 +0200 Subject: [PATCH 11/30] refactor(scoring): fork replicas through a borrowed replica descriptor 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. --- AGENTS.md | 7 +- crates/solverforge-scoring/WIREFRAME.md | 21 ++++-- .../src/director/score_director.rs | 2 + .../src/director/score_director/adapters.rs | 20 ++++-- .../src/director/score_director/descriptor.rs | 71 +++++++++++++++++++ .../director/score_director/incremental.rs | 46 ++++++------ .../src/director/tests/score_director.rs | 33 +++++++-- crates/solverforge-scoring/src/lib.rs | 2 +- crates/solverforge-solver/WIREFRAME.md | 5 +- .../src/phase/localsearch/parallel/tests.rs | 41 +++++++---- crates/solverforge-solver/src/run.rs | 28 ++++++-- 11 files changed, 210 insertions(+), 66 deletions(-) create mode 100644 crates/solverforge-scoring/src/director/score_director/descriptor.rs diff --git a/AGENTS.md b/AGENTS.md index 3586e1d0..7ee10c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,9 +179,10 @@ Clone is acceptable only for: 1. Solution snapshots sent through channels. 2. Parallel search creating worker-private solution copies: partitioned search's partition copies, and parallel local-search evaluation's - evaluator replicas — one per evaluator beyond the main director, made at - phase start and again after the working solution is written outside the - parallel loop's own commits. + evaluator replicas — one working-solution copy per evaluator beyond the + main director, made at phase start and again after the working solution is + written outside the parallel loop's own commits. Replicas borrow the solve's + descriptor; they never copy it. 3. Explicit user-owned result snapshots outside retained scoring state, such as `CollectedVec::to_vec()` when the caller deliberately asks for an owned `Vec`. diff --git a/crates/solverforge-scoring/WIREFRAME.md b/crates/solverforge-scoring/WIREFRAME.md index 91b39b5d..fa2d360d 100644 --- a/crates/solverforge-scoring/WIREFRAME.md +++ b/crates/solverforge-scoring/WIREFRAME.md @@ -212,7 +212,8 @@ src/ │ ├── mod.rs — Re-exports all director types and traits │ ├── traits.rs — Director trait │ ├── score_director.rs — Re-exports ScoreDirector pieces -│ │ ├── score_director/incremental.rs — ScoreDirector (zero-erasure incremental) +│ │ ├── score_director/incremental.rs — ScoreDirector (zero-erasure incremental) +│ │ ├── score_director/descriptor.rs — DirectorDescriptor, ReplicaDescriptor │ │ └── score_director/adapters.rs — Debug and Director trait impls for ScoreDirector │ ├── shadow_aware.rs — SolvableSolution trait and shadow lifecycle notes │ └── tests/ @@ -333,7 +334,7 @@ pub use api::node_sharing::{SharedNodeDiagnostics, SharedNodeId, SharedNodeOpera pub use api::weight_overrides::{ConstraintWeightOverrides, WeightProvider}; // Score Directors -pub use director::score_director::ScoreDirector; +pub use director::score_director::{DirectorDescriptor, ReplicaDescriptor, ScoreDirector}; pub use director::{Director, DirectorScoreState, SolvableSolution}; // Analysis @@ -376,7 +377,7 @@ pub use stream::{ | `snapshot_score_state` | `fn snapshot_score_state(&self) -> DirectorScoreState` | Snapshot committed score state for speculative evaluation | | `restore_score_state` | `fn restore_score_state(&mut self, state: DirectorScoreState)` | Restore a previously snapshotted committed score state | | `reset` | `fn reset(&mut self)` | Default: no-op | -| `fork_replica` | `fn fork_replica(&self) -> Option where Self: Sized` | Independent evaluator replica with initialized incremental state; default: `None` | +| `fork_replica` | `fn fork_replica(&self) -> Option where Self: Sized` | Independent evaluator replica with initialized incremental state, sharing the director's borrowed descriptor; default: `None` | ### `DirectorScoreState` @@ -503,9 +504,9 @@ Stock collectors include `count()`, `sum()`, `load_balance()`, ### Score Directors -**`ScoreDirector`** where `S: PlanningSolution`, `C: ConstraintSet` +**`ScoreDirector`** where `S: PlanningSolution`, `C: ConstraintSet`, `Desc: DirectorDescriptor` - Primary incremental scoring director. Zero-erasure. -- Public methods: `new()`, `with_descriptor()`, `with_replica_constraints()`, `simple()` (convenience for +- Public methods: `new()`, `with_descriptor()`, `simple()` (convenience for `ScoreDirector`), `simple_zero()` (test helper with empty descriptor), `working_solution()`, `working_solution_mut()`, `into_working_solution()`, `calculate_score()`, `before_variable_changed()`, @@ -516,9 +517,17 @@ Stock collectors include `count()`, `sum()`, `load_balance()`, - Returns borrowed constraint metadata views from the monomorphized `ConstraintSet` on demand. - `simple(solution, descriptor, entity_counter)` — creates `ScoreDirector` with empty constraint set - `simple_zero(solution)` — creates `ScoreDirector` with empty descriptor and zero entity counter -- `with_replica_constraints(constraints: fn(&S) -> C)` — records the constraint-set constructor used by `Director::fork_replica`; a replica clones the working solution, builds its constraint set from that clone, and initializes it. Directors without a constructor do not fork. +- `with_descriptor(solution, constraints, descriptor: Desc, entity_counter)` — `Desc` is an owned `SolutionDescriptor` or a `ReplicaDescriptor`; `new()`, `simple()` and `simple_zero()` build owned-descriptor directors. +- `Director::fork_replica` forks only directors holding a `ReplicaDescriptor`: the replica clones the working solution, builds its constraint set from that clone with the recorded constructor, initializes it, and shares the same borrowed descriptor. Owned-descriptor directors return `None`. - Implements `Director` +**`DirectorDescriptor`** (trait) — how a `ScoreDirector` holds its descriptor. +- `solution_descriptor(&self) -> &SolutionDescriptor` +- `replica(&self) -> Option<(Self, fn(&S) -> C)>` — the handle and constraint-set constructor for an evaluator replica; `None` for an owned `SolutionDescriptor`. + +**`ReplicaDescriptor<'d, S, C>`** — `Copy`; a borrowed `&'d SolutionDescriptor` plus the constraint-set constructor `fn(&S) -> C`. +- `new(descriptor: &'d SolutionDescriptor, constraints: fn(&S) -> C)` + ### Constraint Types All implement `IncrementalConstraint`. diff --git a/crates/solverforge-scoring/src/director/score_director.rs b/crates/solverforge-scoring/src/director/score_director.rs index fa81f575..c53cc414 100644 --- a/crates/solverforge-scoring/src/director/score_director.rs +++ b/crates/solverforge-scoring/src/director/score_director.rs @@ -5,6 +5,8 @@ constraint sets instead of trait-object-based scoring. */ mod adapters; +mod descriptor; mod incremental; +pub use descriptor::{DirectorDescriptor, ReplicaDescriptor}; pub use incremental::ScoreDirector; diff --git a/crates/solverforge-scoring/src/director/score_director/adapters.rs b/crates/solverforge-scoring/src/director/score_director/adapters.rs index 781135e0..380fdc0b 100644 --- a/crates/solverforge-scoring/src/director/score_director/adapters.rs +++ b/crates/solverforge-scoring/src/director/score_director/adapters.rs @@ -1,31 +1,37 @@ use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; use solverforge_core::score::Score; +use super::descriptor::DirectorDescriptor; use super::incremental::ScoreDirector; use crate::api::constraint_set::{ConstraintMetadata, ConstraintSet}; use crate::director::Director; -impl std::fmt::Debug for ScoreDirector +impl std::fmt::Debug for ScoreDirector where S: PlanningSolution + std::fmt::Debug, S::Score: std::fmt::Debug, C: ConstraintSet, + Desc: DirectorDescriptor, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ScoreDirector") .field("initialized", &self.is_initialized()) .field("cached_score", &self.get_score()) .field("constraint_count", &self.constraint_count()) - .field("forks_replicas", &self.replica_constraints.is_some()) + .field( + "forks_replicas", + &self.solution_descriptor.replica().is_some(), + ) .finish() } } -impl Director for ScoreDirector +impl Director for ScoreDirector where S: PlanningSolution, S::Score: Score, C: ConstraintSet + Send, + Desc: DirectorDescriptor + Send, { fn working_solution(&self) -> &S { self.working_solution() @@ -44,7 +50,7 @@ where } fn solution_descriptor(&self) -> &SolutionDescriptor { - &self.solution_descriptor + self.solution_descriptor.solution_descriptor() } fn clone_working_solution(&self) -> S { @@ -67,7 +73,11 @@ where } fn total_entity_count(&self) -> Option { - let count: usize = (0..self.solution_descriptor.entity_descriptors.len()) + let count: usize = (0..self + .solution_descriptor + .solution_descriptor() + .entity_descriptors + .len()) .map(|i| (self.entity_counter)(&self.working_solution, i)) .sum(); Some(count) diff --git a/crates/solverforge-scoring/src/director/score_director/descriptor.rs b/crates/solverforge-scoring/src/director/score_director/descriptor.rs new file mode 100644 index 00000000..f9297119 --- /dev/null +++ b/crates/solverforge-scoring/src/director/score_director/descriptor.rs @@ -0,0 +1,71 @@ +use std::marker::PhantomData; + +use solverforge_core::domain::SolutionDescriptor; + +/* How a `ScoreDirector` holds its solution descriptor. + +An owned `SolutionDescriptor` serves a single director. A +`ReplicaDescriptor` borrows the descriptor and carries the constraint-set +constructor, so the director can fork evaluator replicas that share the same +descriptor without copying it. +*/ +pub trait DirectorDescriptor { + fn solution_descriptor(&self) -> &SolutionDescriptor; + + /* A handle for an evaluator replica plus the constructor that builds the + replica's constraint set from its own working solution. `None` when + this director cannot fork replicas. + */ + fn replica(&self) -> Option<(Self, fn(&S) -> C)> + where + Self: Sized; +} + +impl DirectorDescriptor for SolutionDescriptor { + #[inline] + fn solution_descriptor(&self) -> &SolutionDescriptor { + self + } + + fn replica(&self) -> Option<(Self, fn(&S) -> C)> { + None + } +} + +/* A borrowed solution descriptor plus the constraint-set constructor used for +the working solution and every evaluator replica. +*/ +pub struct ReplicaDescriptor<'d, S, C> { + descriptor: &'d SolutionDescriptor, + constraints: fn(&S) -> C, + _phantom: PhantomData (S, C)>, +} + +impl<'d, S, C> ReplicaDescriptor<'d, S, C> { + pub fn new(descriptor: &'d SolutionDescriptor, constraints: fn(&S) -> C) -> Self { + Self { + descriptor, + constraints, + _phantom: PhantomData, + } + } +} + +impl Clone for ReplicaDescriptor<'_, S, C> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for ReplicaDescriptor<'_, S, C> {} + +impl DirectorDescriptor for ReplicaDescriptor<'_, S, C> { + #[inline] + fn solution_descriptor(&self) -> &SolutionDescriptor { + self.descriptor + } + + fn replica(&self) -> Option<(Self, fn(&S) -> C)> { + Some((*self, self.constraints)) + } +} diff --git a/crates/solverforge-scoring/src/director/score_director/incremental.rs b/crates/solverforge-scoring/src/director/score_director/incremental.rs index 0ee52e56..e0ea76bc 100644 --- a/crates/solverforge-scoring/src/director/score_director/incremental.rs +++ b/crates/solverforge-scoring/src/director/score_director/incremental.rs @@ -3,6 +3,7 @@ use std::marker::PhantomData; use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; use solverforge_core::score::Score; +use super::descriptor::DirectorDescriptor; use crate::api::constraint_set::{ConstraintMetadata, ConstraintSet}; /* A zero-erasure score director for incremental scoring. @@ -15,6 +16,9 @@ are known at compile time, enabling complete monomorphization. - `S`: The solution type (must implement `PlanningSolution`) - `C`: The constraint set type (tuple of monomorphized constraints) +- `Desc`: How the solution descriptor is held; an owned `SolutionDescriptor` +by default, or a `ReplicaDescriptor` for directors that fork evaluator +replicas # Example @@ -61,16 +65,17 @@ let score2 = director.calculate_score(); assert_eq!(score, score2); ``` */ -pub struct ScoreDirector +pub struct ScoreDirector where S: PlanningSolution, C: ConstraintSet, + Desc: DirectorDescriptor, { pub(super) working_solution: S, constraints: C, cached_score: S::Score, initialized: bool, - pub(super) solution_descriptor: SolutionDescriptor, + pub(super) solution_descriptor: Desc, /* Entity counter function. Returns the number of entities for the given descriptor index. @@ -78,11 +83,6 @@ where throughout the solver pipeline. */ pub(super) entity_counter: fn(&S, usize) -> usize, - /* Constraint-set constructor used to build evaluator replicas. - - `None` means this director cannot fork replicas. - */ - pub(super) replica_constraints: Option C>, _phantom: PhantomData S>, } @@ -108,7 +108,15 @@ where |_, _| 0, ) } +} +impl ScoreDirector +where + S: PlanningSolution, + S::Score: Score, + C: ConstraintSet, + Desc: DirectorDescriptor, +{ /* Creates a new zero-erasure score director with a solution descriptor. This constructor enables the `Director` trait implementation for @@ -118,13 +126,14 @@ where * `solution` - The initial solution * `constraints` - Tuple of monomorphized constraints (e.g., `(C1, C2, C3)`) - * `solution_descriptor` - Metadata for solver infrastructure + * `solution_descriptor` - Metadata for solver infrastructure: an owned + `SolutionDescriptor`, or a `ReplicaDescriptor` to fork evaluator replicas * `entity_counter` - Function returning entity count for descriptor index */ pub fn with_descriptor( solution: S, constraints: C, - solution_descriptor: SolutionDescriptor, + solution_descriptor: Desc, entity_counter: fn(&S, usize) -> usize, ) -> Self { Self { @@ -134,36 +143,27 @@ where initialized: false, solution_descriptor, entity_counter, - replica_constraints: None, _phantom: PhantomData, } } - /* Records the constructor that builds each evaluator replica's constraint - set. The constructor receives the replica's own working solution. - */ - pub fn with_replica_constraints(mut self, constraints: fn(&S) -> C) -> Self { - self.replica_constraints = Some(constraints); - self - } - - /* Builds an initialized evaluator replica from a clone of the working - solution and a constraint set built for that clone. + /* Builds an initialized evaluator replica: a clone of the working + solution, a constraint set built for that clone, and the same borrowed + descriptor. The working-solution clone is the evaluator replica clone boundary: it happens when replicas are created, never per candidate or per step. */ pub(crate) fn fork_replica_impl(&self) -> Option { - let constraints = self.replica_constraints?; + let (descriptor, constraints) = self.solution_descriptor.replica()?; let solution = self.working_solution.clone(); let replica_constraints = constraints(&solution); let mut replica = Self::with_descriptor( solution, replica_constraints, - self.solution_descriptor.clone(), + descriptor, self.entity_counter, ); - replica.replica_constraints = Some(constraints); replica.calculate_score_impl(); Some(replica) } diff --git a/crates/solverforge-scoring/src/director/tests/score_director.rs b/crates/solverforge-scoring/src/director/tests/score_director.rs index 0df83c5e..9020c3ad 100644 --- a/crates/solverforge-scoring/src/director/tests/score_director.rs +++ b/crates/solverforge-scoring/src/director/tests/score_director.rs @@ -2,7 +2,7 @@ use crate::api::constraint_set::IncrementalConstraint; use crate::constraint::incremental::IncrementalUniConstraint; -use crate::director::score_director::ScoreDirector; +use crate::director::score_director::{ReplicaDescriptor, ScoreDirector}; use crate::stream::collection_extract::{source, ChangeSource}; use crate::Director; use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; @@ -308,6 +308,10 @@ fn test_add_then_remove_value() { assert_eq!(director.get_score(), SoftScore::of(-2)); } +fn test_descriptor() -> SolutionDescriptor { + SolutionDescriptor::new("TestSolution", TypeId::of::()) +} + fn unassigned_constraints( _solution: &TestSolution, ) -> (impl IncrementalConstraint,) { @@ -315,7 +319,7 @@ fn unassigned_constraints( } #[test] -fn fork_replica_requires_replica_constraints() { +fn fork_replica_requires_a_replica_descriptor() { let solution = TestSolution { values: vec![Some(1), None], score: None, @@ -332,8 +336,13 @@ fn fork_replica_matches_score_and_stays_independent() { values: vec![Some(1), None, None], score: None, }; - let mut director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)) - .with_replica_constraints(unassigned_constraints); + let descriptor = test_descriptor(); + let mut director = ScoreDirector::with_descriptor( + solution.clone(), + unassigned_constraints(&solution), + ReplicaDescriptor::new(&descriptor, unassigned_constraints), + |_, _| 0, + ); let main_score = director.calculate_score(); let mut replica = director @@ -341,6 +350,13 @@ fn fork_replica_matches_score_and_stays_independent() { .expect("director with a constraint constructor must fork"); assert!(replica.is_initialized()); assert_eq!(replica.get_score(), main_score); + assert!( + std::ptr::eq( + Director::solution_descriptor(&replica), + Director::solution_descriptor(&director) + ), + "a replica must share the descriptor, not copy it" + ); replica.before_variable_changed(0, 1); replica.working_solution_mut().values[1] = Some(7); @@ -357,8 +373,13 @@ fn fork_of_uninitialized_director_initializes_the_replica() { values: vec![None, Some(3), None, None], score: None, }; - let director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution)) - .with_replica_constraints(unassigned_constraints); + let descriptor = test_descriptor(); + let director = ScoreDirector::with_descriptor( + solution.clone(), + unassigned_constraints(&solution), + ReplicaDescriptor::new(&descriptor, unassigned_constraints), + |_, _| 0, + ); assert!(!director.is_initialized()); let replica = director diff --git a/crates/solverforge-scoring/src/lib.rs b/crates/solverforge-scoring/src/lib.rs index 94b596bf..12a9e248 100644 --- a/crates/solverforge-scoring/src/lib.rs +++ b/crates/solverforge-scoring/src/lib.rs @@ -44,7 +44,7 @@ Score Directors ============================================================================ */ -pub use director::score_director::ScoreDirector; +pub use director::score_director::{DirectorDescriptor, ReplicaDescriptor, ScoreDirector}; pub use director::{Director, DirectorScoreState, SolvableSolution}; /* ============================================================================ diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index cf5c4ee8..fa967772 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1264,7 +1264,10 @@ acceptor and forager. The selected move is applied to every replica by borrow before the owned commit, and each replica must reach the committed step score. Replicas are forked from the working solution through `Director::fork_replica`, again whenever the working solution was written -outside the loop's own commits. Contract: +outside the loop's own commits. The solve entry points build the main +`ScoreDirector` with a `ReplicaDescriptor` that borrows the solve's +descriptor, so replicas share it; only the working solution is copied. +Contract: - One evaluator runs the sequential loop: same candidates, checks, order, and telemetry. diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs index 338e404a..8efb3123 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -5,7 +5,7 @@ use solverforge_core::score::SoftScore; use solverforge_core::{ConstraintRef, ImpactType}; use solverforge_scoring::api::constraint_set::IncrementalConstraint; use solverforge_scoring::stream::collection_extract::{source, ChangeSource}; -use solverforge_scoring::{ConstraintSet, IncrementalUniConstraint, ScoreDirector}; +use solverforge_scoring::{Director, IncrementalUniConstraint, ReplicaDescriptor, ScoreDirector}; use super::EvaluatorPool; use crate::heuristic::r#move::ChangeMove; @@ -49,17 +49,20 @@ fn starting_solution() -> NQueensSolution { NQueensSolution::with_rows(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8]) } -fn forkable_director( - solution: NQueensSolution, -) -> ScoreDirector> { - let constraints = queen_constraints(&solution); - ScoreDirector::with_descriptor( - solution, - constraints, - create_nqueens_descriptor(), - |s, _| s.queens.len(), - ) - .with_replica_constraints(queen_constraints) +/* Builds a director that can fork replicas. A macro, because the director's +type names the opaque constraint set and the borrowed descriptor. +*/ +macro_rules! forkable_director { + ($solution:expr, $descriptor:expr) => {{ + let solution: NQueensSolution = $solution; + let constraints = queen_constraints(&solution); + ScoreDirector::with_descriptor( + solution, + constraints, + ReplicaDescriptor::new($descriptor, queen_constraints), + |s: &NQueensSolution, _| s.queens.len(), + ) + }}; } type QueenSelector = ChangeMoveSelector< @@ -98,7 +101,9 @@ fn run( where Fo: LocalSearchForager, { - let mut solver_scope = SolverScope::new(forkable_director(starting_solution())).with_seed(7); + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = + SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(7); solver_scope.start_solving(); solver_scope.inphase_move_count_limit = move_limit; let mut source = selector(); @@ -160,7 +165,8 @@ fn pool_requires_a_forkable_director() { #[test] fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { - let mut solver_scope = SolverScope::new(forkable_director(starting_solution())); + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); solver_scope.start_solving(); solver_scope.calculate_score(); let mut pool = EvaluatorPool::::new(4); @@ -191,6 +197,10 @@ fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { for replica in pool.replicas() { assert_eq!(replica.working_solution().queens[0].row, Some(7)); assert_eq!(replica.working_solution().score(), Some(main_score)); + assert!( + std::ptr::eq(Director::solution_descriptor(replica), &descriptor), + "replicas must share the solve's descriptor, not copy it" + ); } } @@ -234,9 +244,10 @@ fn in_phase_move_limit_reserves_the_window() { #[test] fn replicas_follow_solution_replacement() { + let descriptor = create_nqueens_descriptor(); let run_with_replacement = |evaluator_count: usize| { let mut solver_scope = - SolverScope::new(forkable_director(starting_solution())).with_seed(11); + SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(11); solver_scope.start_solving(); let mut source = selector(); let mut acceptor = HillClimbingAcceptor::new(); diff --git a/crates/solverforge-solver/src/run.rs b/crates/solverforge-solver/src/run.rs index 4e9014ac..ba1be095 100644 --- a/crates/solverforge-solver/src/run.rs +++ b/crates/solverforge-solver/src/run.rs @@ -12,7 +12,7 @@ use std::path::Path; use solverforge_config::{SolverConfig, TerminationConfig}; use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; use solverforge_core::score::{ParseableScore, Score}; -use solverforge_scoring::{ConstraintSet, Director, ScoreDirector}; +use solverforge_scoring::{ConstraintSet, Director, ReplicaDescriptor, ScoreDirector}; use tracing::info; use crate::builder::{RuntimeExtensionRegistry, Search}; @@ -207,6 +207,18 @@ where S: PlanningSolution, S::Score: Score + ParseableScore, C: ConstraintSet, +{ + build_director_termination(config, default_secs) +} + +fn build_director_termination( + config: &SolverConfig, + default_secs: u64, +) -> (AnyTermination, Option) +where + S: PlanningSolution, + S::Score: Score + ParseableScore, + D: Director, { let ConfiguredTermination { time_limit: configured_time_limit, @@ -549,7 +561,12 @@ where S: PlanningSolution, S::Score: Score + ParseableScore, C: ConstraintSet, - Runner: Phase, ChannelProgressCallback> + Send + std::fmt::Debug, + Runner: for<'d> Phase< + S, + ScoreDirector>, + ChannelProgressCallback, + > + Send + + std::fmt::Debug, BuildRunner: FnOnce(&SolverConfig, &SolutionDescriptor) -> RuntimeBuildResult, { log_scale(&solution); @@ -557,12 +574,11 @@ where let director = ScoreDirector::with_descriptor( solution, main_constraints, - descriptor.clone(), + ReplicaDescriptor::new(&descriptor, constraints), entity_count_by_descriptor, - ) - .with_replica_constraints(constraints); + ); - let (termination, time_limit) = build_termination::(&config, default_time_limit_secs); + let (termination, time_limit) = build_director_termination(&config, default_time_limit_secs); let execution_policy = configured_execution_policy::(&config, default_time_limit_secs, time_limit); From bdcdb644587b5ab50153c8dea21e0f6140a14008 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 20:59:19 +0200 Subject: [PATCH 12/30] perf(solver): lend move tabu signatures to the acceptor 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. --- .../src/phase/localsearch/phase/decision.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs index b26a01c1..ba7f71b2 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -172,16 +172,22 @@ pub(super) fn consume_candidate( return; } }; + // Moves that own a prebuilt signature lend it; only dynamic builders + // produce one, and it lives for this decision alone. + let built_signature; let move_signature = if requires_move_signatures { match mov.borrowed_tabu_signature() { - Some(borrowed) => Some(borrowed.clone()), - None => Some(mov.tabu_signature(step_scope.score_director())), + Some(borrowed) => Some(borrowed), + None => { + built_signature = mov.tabu_signature(step_scope.score_director()); + Some(&built_signature) + } } } else { None }; - let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_ref()); + let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature); record_evaluated_move(step_scope, selector_index, elapsed); if accepted { From 4e6917523d443d3ed0c17453da42aea2170e7777 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Thu, 22 Oct 2026 22:03:26 +0200 Subject: [PATCH 13/30] feat(solver): size parallel evaluation windows from the step's consumption 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. --- crates/solverforge-solver/WIREFRAME.md | 13 ++++--- .../src/phase/localsearch/parallel/pool.rs | 37 +++++++++++++++++-- .../src/phase/localsearch/parallel/tests.rs | 33 ++++++++++++++++- .../src/phase/localsearch/phase/candidates.rs | 4 ++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index fa967772..63a5a5ca 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1271,11 +1271,14 @@ Contract: - One evaluator runs the sequential loop: same candidates, checks, order, and telemetry. -- The window holds 32 candidates whenever two or more evaluators run, so the - trajectory at fixed work is identical for every evaluator count of two or - more. Selectors that keep no stream state across steps also match the - sequential trajectory; ruin selectors (cross-step RNG streams) and lazily - activated providers follow a different, equally reproducible trajectory. +- Windows adapt to the step: a step's first window matches the number of + candidates the previous step consumed (at least one per evaluator), and each + further window in the step doubles, up to 32. Candidates are always + consumed in canonical order, so selectors that keep no stream state across + steps reach the sequential trajectory for every evaluator count. Ruin + selectors (cross-step RNG streams) and lazily activated providers see the + speculative pulls, so their trajectory depends on the evaluator count; it is + reproducible for a given count. - `moves_evaluated`, `score_calculations`, and the move-count and score-calculation limits count only consumed candidates; the window never exceeds the remaining in-phase move or score-calculation budget. diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index 5a8184a2..c51811d2 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -11,10 +11,13 @@ use crate::phase::localsearch::evaluation::TrialOutcome; use crate::phase::localsearch::phase::WindowSlot; use crate::scope::{ProgressCallback, SolverScope}; -/* Candidates per parallel window. Fixed so the trajectory does not depend on -the number of evaluators. +/* Largest parallel window. Windows adapt to how many candidates a step +consumes: a step's first window matches the previous step's consumption +(at least one candidate per evaluator), and each further window in the same +step doubles, up to this cap. Candidates past the decision point are +speculative work, so a window much longer than the step wastes evaluations. */ -pub(crate) const PARALLEL_EVALUATION_WINDOW: usize = 32; +pub(crate) const MAX_PARALLEL_EVALUATION_WINDOW: usize = 32; pub(super) type TrialOutputs = Vec<(usize, TrialOutcome, Duration)>; @@ -35,6 +38,10 @@ where // Window buffers lent to each step, so steps reuse their capacity. window_slots: Vec, window_results: WindowResults, + // Size of the next window in the current step. + window: usize, + // Candidates the previous step consumed. + last_step_consumed: usize, synced_generation: Option, _phantom: PhantomData (S, M)>, } @@ -63,6 +70,8 @@ where main_outputs: Vec::new(), window_slots: Vec::new(), window_results: Vec::new(), + window: 1, + last_step_consumed: 0, synced_generation: None, _phantom: PhantomData, } @@ -92,6 +101,17 @@ where !self.replicas.is_empty() } + /// Starts a step: its first window matches the previous step's + /// consumption, at least one candidate per evaluator. + pub(crate) fn begin_step(&mut self) { + self.window = self.last_step_consumed.clamp( + self.requested.min(MAX_PARALLEL_EVALUATION_WINDOW), + MAX_PARALLEL_EVALUATION_WINDOW, + ); + } + + /// Size of the next window, capped by the remaining evaluation budget. + /// Sequential evaluation always uses a window of one. pub(crate) fn window_capacity(&self, remaining: Option) -> usize { if !self.is_parallel() { return 1; @@ -99,7 +119,16 @@ where let remaining = remaining.map_or(usize::MAX, |remaining| { usize::try_from(remaining).unwrap_or(usize::MAX) }); - PARALLEL_EVALUATION_WINDOW.min(remaining).max(1) + self.window.min(remaining).max(1) + } + + /// The step outlasted its window: the next one doubles, up to the cap. + pub(crate) fn grow_window(&mut self) { + self.window = (self.window * 2).min(MAX_PARALLEL_EVALUATION_WINDOW); + } + + pub(crate) fn finish_step(&mut self, consumed: u64) { + self.last_step_consumed = usize::try_from(consumed).unwrap_or(usize::MAX); } /* Rebuilds the replicas when the working solution was written since they diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs index 8efb3123..1b2ccdbb 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -175,8 +175,6 @@ fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { pool.sync(&solver_scope); assert_eq!(nqueens_clone_count(), 3); assert_eq!(pool.replicas().count(), 3); - assert_eq!(pool.window_capacity(None), 32); - assert_eq!(pool.window_capacity(Some(5)), 5); pool.sync(&solver_scope); assert_eq!( @@ -299,3 +297,34 @@ fn evaluator_count_resolution() { 2.min(rayon::current_num_threads()) ); } + +#[test] +fn windows_track_the_steps_consumption() { + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(4); + pool.sync(&solver_scope); + + // First step: one candidate per evaluator, doubling while the step lasts. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 4); + pool.grow_window(); + assert_eq!(pool.window_capacity(None), 8); + pool.grow_window(); + pool.grow_window(); + pool.grow_window(); + assert_eq!(pool.window_capacity(None), 32); + assert_eq!(pool.window_capacity(Some(5)), 5); + pool.finish_step(23); + + // The next step starts at the previous step's consumption. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 23); + pool.finish_step(2); + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 4); + pool.finish_step(500); + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 32); +} diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 7a0d5cb0..44a4f6b7 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -83,6 +83,8 @@ where let mut control_polls = 0usize; let mut control_latched = false; let (mut window, mut results) = evaluators.take_window_buffers(); + let consumed_before = state.evaluated_moves; + evaluators.begin_step(); while !forager.is_quit_early() { if state.interrupted { break; @@ -204,8 +206,10 @@ where if state.interrupted || exhausted { break; } + evaluators.grow_window(); } + evaluators.finish_step(state.evaluated_moves - consumed_before); evaluators.restore_window_buffers(window, results); state } From 7496f6833ddb5a022a2c037203c9d854e945c996 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 08:33:51 +0100 Subject: [PATCH 14/30] perf(solver): aim parallel windows at the previous step's consumption 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. --- crates/solverforge-solver/WIREFRAME.md | 7 +-- .../src/phase/localsearch/parallel/pool.rs | 43 ++++++++++--------- .../src/phase/localsearch/parallel/tests.rs | 29 +++++++------ .../src/phase/localsearch/phase/candidates.rs | 2 +- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 63a5a5ca..4d81ea4f 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1271,9 +1271,10 @@ Contract: - One evaluator runs the sequential loop: same candidates, checks, order, and telemetry. -- Windows adapt to the step: a step's first window matches the number of - candidates the previous step consumed (at least one per evaluator), and each - further window in the step doubles, up to 32. Candidates are always +- Windows aim at the number of candidates the previous step consumed: they + cover that target in windows of at most 32, then continue one candidate per + evaluator, so speculative work past the decision point stays near one + window of evaluators when step lengths are stable. Candidates are always consumed in canonical order, so selectors that keep no stream state across steps reach the sequential trajectory for every evaluator count. Ruin selectors (cross-step RNG streams) and lazily activated providers see the diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index c51811d2..3607f63f 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -11,11 +11,11 @@ use crate::phase::localsearch::evaluation::TrialOutcome; use crate::phase::localsearch::phase::WindowSlot; use crate::scope::{ProgressCallback, SolverScope}; -/* Largest parallel window. Windows adapt to how many candidates a step -consumes: a step's first window matches the previous step's consumption -(at least one candidate per evaluator), and each further window in the same -step doubles, up to this cap. Candidates past the decision point are -speculative work, so a window much longer than the step wastes evaluations. +/* Largest parallel window. Windows aim at the number of candidates the +previous step consumed: they cover that target in windows of at most this +size, then continue one candidate per evaluator. Candidates past the +decision point are speculative work, so overshooting the step wastes +evaluations. */ pub(crate) const MAX_PARALLEL_EVALUATION_WINDOW: usize = 32; @@ -38,10 +38,10 @@ where // Window buffers lent to each step, so steps reuse their capacity. window_slots: Vec, window_results: WindowResults, - // Size of the next window in the current step. - window: usize, - // Candidates the previous step consumed. + // Candidates the previous step consumed: this step's target. last_step_consumed: usize, + // Candidates dispatched in the current step. + step_dispatched: usize, synced_generation: Option, _phantom: PhantomData (S, M)>, } @@ -70,8 +70,8 @@ where main_outputs: Vec::new(), window_slots: Vec::new(), window_results: Vec::new(), - window: 1, last_step_consumed: 0, + step_dispatched: 0, synced_generation: None, _phantom: PhantomData, } @@ -101,30 +101,31 @@ where !self.replicas.is_empty() } - /// Starts a step: its first window matches the previous step's - /// consumption, at least one candidate per evaluator. pub(crate) fn begin_step(&mut self) { - self.window = self.last_step_consumed.clamp( - self.requested.min(MAX_PARALLEL_EVALUATION_WINDOW), - MAX_PARALLEL_EVALUATION_WINDOW, - ); + self.step_dispatched = 0; } - /// Size of the next window, capped by the remaining evaluation budget. - /// Sequential evaluation always uses a window of one. + /// Size of the next window: the rest of the previous step's consumption, + /// at least one candidate per evaluator and at most + /// `MAX_PARALLEL_EVALUATION_WINDOW`, capped by the remaining evaluation + /// budget. Sequential evaluation always uses a window of one. pub(crate) fn window_capacity(&self, remaining: Option) -> usize { if !self.is_parallel() { return 1; } + let per_evaluator = self.requested.min(MAX_PARALLEL_EVALUATION_WINDOW); + let window = self + .last_step_consumed + .saturating_sub(self.step_dispatched) + .clamp(per_evaluator, MAX_PARALLEL_EVALUATION_WINDOW); let remaining = remaining.map_or(usize::MAX, |remaining| { usize::try_from(remaining).unwrap_or(usize::MAX) }); - self.window.min(remaining).max(1) + window.min(remaining).max(1) } - /// The step outlasted its window: the next one doubles, up to the cap. - pub(crate) fn grow_window(&mut self) { - self.window = (self.window * 2).min(MAX_PARALLEL_EVALUATION_WINDOW); + pub(crate) fn record_window(&mut self, len: usize) { + self.step_dispatched += len; } pub(crate) fn finish_step(&mut self, consumed: u64) { diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs index 1b2ccdbb..9ebb2d2b 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -299,32 +299,35 @@ fn evaluator_count_resolution() { } #[test] -fn windows_track_the_steps_consumption() { +fn windows_aim_at_the_previous_steps_consumption() { let descriptor = create_nqueens_descriptor(); let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); solver_scope.start_solving(); let mut pool = EvaluatorPool::::new(4); pool.sync(&solver_scope); - // First step: one candidate per evaluator, doubling while the step lasts. + // No history: one candidate per evaluator. pool.begin_step(); assert_eq!(pool.window_capacity(None), 4); - pool.grow_window(); - assert_eq!(pool.window_capacity(None), 8); - pool.grow_window(); - pool.grow_window(); - pool.grow_window(); - assert_eq!(pool.window_capacity(None), 32); - assert_eq!(pool.window_capacity(Some(5)), 5); + pool.record_window(4); + assert_eq!(pool.window_capacity(None), 4); pool.finish_step(23); - // The next step starts at the previous step's consumption. + // Cover the previous consumption, then continue one per evaluator. pool.begin_step(); assert_eq!(pool.window_capacity(None), 23); - pool.finish_step(2); - pool.begin_step(); + assert_eq!(pool.window_capacity(Some(5)), 5); + pool.record_window(23); assert_eq!(pool.window_capacity(None), 4); - pool.finish_step(500); + pool.finish_step(70); + + // Long steps run in capped windows until the target, then per evaluator. pool.begin_step(); assert_eq!(pool.window_capacity(None), 32); + pool.record_window(32); + assert_eq!(pool.window_capacity(None), 32); + pool.record_window(32); + assert_eq!(pool.window_capacity(None), 6); + pool.record_window(6); + assert_eq!(pool.window_capacity(None), 4); } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 44a4f6b7..6cf41ee8 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -206,7 +206,7 @@ where if state.interrupted || exhausted { break; } - evaluators.grow_window(); + evaluators.record_window(window.len()); } evaluators.finish_step(state.evaluated_moves - consumed_before); From 66b020b3511d49658a75c8b12360be7056642698 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 21:22:51 +0100 Subject: [PATCH 15/30] perf(solver): select the local-search step loop once per phase 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. --- crates/solverforge-solver/WIREFRAME.md | 12 +- .../src/phase/localsearch/evaluation.rs | 115 ++++++- .../src/phase/localsearch/parallel/pool.rs | 5 +- .../src/phase/localsearch/parallel/tests.rs | 1 - .../src/phase/localsearch/phase.rs | 54 +++- .../src/phase/localsearch/phase/candidates.rs | 54 ++-- .../src/phase/localsearch/phase/decision.rs | 8 +- .../src/phase/localsearch/phase/evaluator.rs | 158 ++++++++++ .../src/phase/localsearch/phase/sequential.rs | 289 ++++++++++++++++++ .../src/phase/localsearch/phase/step.rs | 18 +- .../src/phase/localsearch/vnd/phase.rs | 2 +- 11 files changed, 645 insertions(+), 71 deletions(-) create mode 100644 crates/solverforge-solver/src/phase/localsearch/phase/evaluator.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 4d81ea4f..1621b13a 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -268,8 +268,10 @@ src/ │ │ ├── mod.rs — Acceptor, local-search acceptor/forager, and LocalSearchPhase re-exports │ │ ├── evaluation.rs — Trial kernel (run_trial), candidate accounting (account_trial), and hard-delta classification │ │ ├── phase.rs — LocalSearchPhase -│ │ ├── phase/candidates.rs — Candidate loop for one step -│ │ ├── phase/decision.rs — Generated-candidate records and in-order candidate consumption +│ │ ├── phase/candidates.rs — Step candidate state and the parallel window loop +│ │ ├── phase/decision.rs — Generated-candidate records and in-order candidate consumption for windows +│ │ ├── phase/evaluator.rs — Step evaluation strategy, selected once per phase: sequential loop or evaluator pool +│ │ ├── phase/sequential.rs — One-evaluator candidate loop │ │ ├── phase/step.rs — One step: candidate selection and commit │ │ ├── phase/tests.rs — Tests │ │ ├── forager.rs — LocalSearchForager trait, AcceptedCountForager, FirstAcceptedForager, BestScoreForager, re-exports @@ -1269,8 +1271,10 @@ outside the loop's own commits. The solve entry points build the main descriptor, so replicas share it; only the working solution is copied. Contract: -- One evaluator runs the sequential loop: same candidates, checks, order, and - telemetry. +- The evaluator count selects the step loop once per phase. One evaluator + runs the sequential loop, a separate monomorphized loop with no pool, + window, or replica state; two or more run the parallel window loop. Both + take the same candidates, checks, order, and decisions. - Windows aim at the number of candidates the previous step consumed: they cover that target in windows of at most 32, then continue one candidate per evaluator, so speculative work past the decision point stays near one diff --git a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs index ed41a16d..3d438209 100644 --- a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs +++ b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs @@ -40,15 +40,90 @@ where ProgressCb: ProgressCallback, M: Move, { - let outcome = run_trial(mov, step_scope.score_director_mut(), journal_pool); - account_trial( - mov, - step_scope, - reference_score, - selector_index, - evaluation_started.elapsed(), - outcome, - ) + if move_changes_pinned(mov, step_scope.score_director()) + || !mov.is_doable_with_working_preview(step_scope.score_director_mut()) + { + let move_label = mov.telemetry_label(); + record_evaluated_move(step_scope, selector_index, evaluation_started); + step_scope + .phase_scope_mut() + .record_move_kind_evaluated_unscored(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_not_doable(selector_index); + } else { + step_scope.phase_scope_mut().record_move_not_doable(); + } + step_scope + .phase_scope_mut() + .record_move_kind_not_doable(move_label); + return CandidateEvaluation::NotDoable; + } + + let score_state = step_scope.score_director().snapshot_score_state(); + let undo = mov.do_move_with_scratch(step_scope.score_director_mut(), journal_pool); + let move_score = step_scope.score_director_mut().calculate_score(); + mov.undo_move_with_scratch(step_scope.score_director_mut(), undo, journal_pool); + step_scope + .score_director_mut() + .restore_score_state(score_state); + + step_scope.phase_scope_mut().record_score_calculation(); + + let hard_delta = hard_score_delta(reference_score, move_score); + match hard_delta { + Some(HardScoreDelta::Improving) => { + step_scope.phase_scope_mut().record_move_hard_improving(); + } + Some(HardScoreDelta::Neutral) => { + step_scope.phase_scope_mut().record_move_hard_neutral(); + } + Some(HardScoreDelta::Worse) => { + step_scope.phase_scope_mut().record_move_hard_worse(); + } + None => {} + } + + if mov.requires_hard_improvement() && hard_delta != Some(HardScoreDelta::Improving) { + let move_label = mov.telemetry_label(); + record_evaluated_move(step_scope, selector_index, evaluation_started); + step_scope + .phase_scope_mut() + .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_acceptor_rejected(selector_index); + } else { + step_scope.phase_scope_mut().record_move_acceptor_rejected(); + } + step_scope + .phase_scope_mut() + .record_move_kind_acceptor_rejected(move_label, move_score.compare(&reference_score)); + return CandidateEvaluation::RejectedByHardImprovement(move_score); + } + + if mov.requires_score_improvement() && move_score <= reference_score { + let move_label = mov.telemetry_label(); + record_evaluated_move(step_scope, selector_index, evaluation_started); + step_scope + .phase_scope_mut() + .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_acceptor_rejected(selector_index); + } else { + step_scope.phase_scope_mut().record_move_acceptor_rejected(); + } + step_scope + .phase_scope_mut() + .record_move_kind_acceptor_rejected(move_label, move_score.compare(&reference_score)); + return CandidateEvaluation::RejectedByScoreImprovement(move_score); + } + + CandidateEvaluation::Scored(move_score) } /* Runs the trial transaction on `director`: the pinned/doable check, then @@ -99,7 +174,7 @@ where let move_score = match outcome { TrialOutcome::NotDoable => { let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, elapsed); + record_evaluated_elapsed(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() .record_move_kind_evaluated_unscored(move_label); @@ -136,7 +211,7 @@ where if mov.requires_hard_improvement() && hard_delta != Some(HardScoreDelta::Improving) { let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, elapsed); + record_evaluated_elapsed(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); @@ -155,7 +230,7 @@ where if mov.requires_score_improvement() && move_score <= reference_score { let move_label = mov.telemetry_label(); - record_evaluated_move(step_scope, selector_index, elapsed); + record_evaluated_elapsed(step_scope, selector_index, elapsed); step_scope .phase_scope_mut() .record_move_kind_evaluated(move_label, move_score.compare(&reference_score)); @@ -177,6 +252,22 @@ where #[inline] pub(crate) fn record_evaluated_move( + step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, + selector_index: Option, + evaluation_started: Instant, +) where + S: PlanningSolution, + D: Director, + ProgressCb: ProgressCallback, +{ + record_evaluated_elapsed(step_scope, selector_index, evaluation_started.elapsed()); +} + +/* Records one evaluated move whose trial time was measured elsewhere, as +parallel evaluation does on the evaluator that ran the trial. +*/ +#[inline] +pub(crate) fn record_evaluated_elapsed( step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, selector_index: Option, elapsed: Duration, diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index 3607f63f..feaa593a 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -108,11 +108,8 @@ where /// Size of the next window: the rest of the previous step's consumption, /// at least one candidate per evaluator and at most /// `MAX_PARALLEL_EVALUATION_WINDOW`, capped by the remaining evaluation - /// budget. Sequential evaluation always uses a window of one. + /// budget. Only the parallel loop sizes windows. pub(crate) fn window_capacity(&self, remaining: Option) -> usize { - if !self.is_parallel() { - return 1; - } let per_evaluator = self.requested.min(MAX_PARALLEL_EVALUATION_WINDOW); let window = self .last_step_consumed diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs index 9ebb2d2b..d9143b9e 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs @@ -146,7 +146,6 @@ fn pool_with_one_evaluator_never_forks() { pool.sync(&solver_scope); assert!(!pool.is_parallel()); - assert_eq!(pool.window_capacity(None), 1); } #[test] diff --git a/crates/solverforge-solver/src/phase/localsearch/phase.rs b/crates/solverforge-solver/src/phase/localsearch/phase.rs index b2b25e17..dd90dfdf 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase.rs @@ -20,10 +20,13 @@ use crate::stats::{format_duration, whole_units_per_second, CandidateTracePullTo mod candidates; mod decision; +mod evaluator; +mod sequential; mod step; pub(in crate::phase::localsearch) use decision::WindowSlot; +use evaluator::{SequentialEvaluator, StepEvaluator}; use step::{execute_step, StepOutcome}; const STEP_ACCEPTED_LABEL_LIMIT: usize = 32; @@ -234,9 +237,11 @@ where } } -/// Executes the one local-search loop while borrowing its resource only for +/// Executes the local-search phase while borrowing its resource only for /// cursor opening and individual candidate pulls. Both stock phases and the -/// compiled runtime call this function; neither owns a second loop. +/// compiled runtime call this function. The evaluator count selects the step +/// loop once, before the first step: one evaluator runs the sequential loop, +/// more run parallel windows across evaluator replicas. #[allow(clippy::too_many_arguments)] pub(crate) fn solve_local_search_with_resources( move_source: &mut Source, @@ -254,6 +259,48 @@ pub(crate) fn solve_local_search_with_resources( Source: MoveCursorSource + Debug + Send, A: Acceptor, Fo: LocalSearchForager, +{ + if evaluator_count > 1 { + run_local_search( + move_source, + resources, + acceptor, + forager, + step_limit, + &mut EvaluatorPool::::new(evaluator_count), + solver_scope, + ); + } else { + run_local_search( + move_source, + resources, + acceptor, + forager, + step_limit, + &mut SequentialEvaluator, + solver_scope, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn run_local_search( + move_source: &mut Source, + resources: &mut Source::Resources, + acceptor: &mut A, + forager: &mut Fo, + step_limit: Option, + evaluator: &mut E, + solver_scope: &mut SolverScope, +) where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + Source: MoveCursorSource + Debug + Send, + A: Acceptor, + Fo: LocalSearchForager, + E: StepEvaluator, { let mut phase_scope = PhaseScope::with_phase_type(solver_scope, 0, "Local Search"); let phase_index = phase_scope.phase_index(); @@ -267,7 +314,6 @@ pub(crate) fn solve_local_search_with_resources( ); acceptor.phase_started(&last_step_score); - let mut evaluators = EvaluatorPool::::new(evaluator_count); let start_time = Instant::now(); loop { if phase_scope.solver_scope_mut().should_terminate() { @@ -284,7 +330,7 @@ pub(crate) fn solve_local_search_with_resources( forager, &mut phase_scope, &mut last_step_score, - &mut evaluators, + evaluator, ) { StepOutcome::Continue | StepOutcome::Restart => continue, StepOutcome::Terminate => break, diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 6cf41ee8..07f85846 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -1,4 +1,4 @@ -//! Candidate streaming for one local-search step. +//! Candidate state for one local-search step and the parallel window loop. use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; @@ -10,7 +10,6 @@ use crate::heuristic::selector::move_selector::{ use crate::phase::control::{ should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, }; -use crate::phase::localsearch::evaluation::run_trial; use crate::phase::localsearch::parallel::{ evaluate_window, remaining_evaluation_budget, EvaluatorPool, }; @@ -45,8 +44,11 @@ impl CandidateLoopState { } } +/// The parallel loop: produce a window serially on the main director, +/// evaluate it across the evaluator pool, and consume the results in +/// candidate order through the same checks and decisions. #[allow(clippy::too_many_arguments)] -pub(super) fn evaluate_candidates( +pub(super) fn evaluate_candidate_windows( cursor: &mut C, resources: &mut Resources, step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, @@ -75,10 +77,8 @@ where // arbitrary-long journals do not grow the heap per trial. // // Each pass produces a window of candidates serially on the main - // director, evaluates it (inline for one evaluator, across the evaluator - // pool otherwise), and consumes the results in candidate order. With a - // window of one, the checks, pulls and decisions are exactly the - // sequential loop's. + // director, evaluates it across the evaluator pool, and consumes the + // results in candidate order through the sequential loop's checks. let mut journal_pool: Vec> = Vec::new(); let mut control_polls = 0usize; let mut control_latched = false; @@ -143,31 +143,21 @@ where } // Evaluation. - if evaluators.is_parallel() { - let candidates: Vec<_> = window - .iter() - .map(|slot| { - cursor - .candidate(slot.id) - .expect("window candidate must remain borrowable") - }) - .collect(); - evaluate_window( - &candidates, - step_scope.score_director_mut(), - &mut journal_pool, - evaluators, - &mut results, - ); - } else { - let mov = cursor - .candidate(window[0].id) - .expect("discovered candidate id must remain borrowable"); - let started = std::time::Instant::now(); - let outcome = run_trial(&mov, step_scope.score_director_mut(), &mut journal_pool); - results.clear(); - results.push(Some((outcome, started.elapsed()))); - } + let candidates: Vec<_> = window + .iter() + .map(|slot| { + cursor + .candidate(slot.id) + .expect("window candidate must remain borrowable") + }) + .collect(); + evaluate_window( + &candidates, + step_scope.score_director_mut(), + &mut journal_pool, + evaluators, + &mut results, + ); // Consumption in candidate order. for index in 0..window.len() { diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs index ba7f71b2..e3c2e293 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -11,7 +11,7 @@ use tracing::trace; use crate::heuristic::r#move::Move; use crate::heuristic::selector::move_selector::{CandidateId, ResourceMoveCursor}; use crate::phase::localsearch::evaluation::{ - account_trial, record_evaluated_move, CandidateEvaluation, TrialOutcome, + account_trial, record_evaluated_elapsed, CandidateEvaluation, TrialOutcome, }; use crate::phase::localsearch::{Acceptor, ForagerDecision, LocalSearchForager}; use crate::scope::{ProgressCallback, StepScope}; @@ -20,7 +20,7 @@ use crate::stats::{CandidateTraceDisposition, CandidateTracePullToken, Candidate use super::candidates::CandidateLoopState; use super::{candidate_selector_label, take_trace_token}; -const PROGRESS_POLL_INTERVAL: u64 = 256; +pub(super) const PROGRESS_POLL_INTERVAL: u64 = 256; /// A generated candidate waiting for its trial result. pub(in crate::phase::localsearch) struct WindowSlot { @@ -189,7 +189,7 @@ pub(super) fn consume_candidate( let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature); - record_evaluated_move(step_scope, selector_index, elapsed); + record_evaluated_elapsed(step_scope, selector_index, elapsed); if accepted { step_scope .phase_scope_mut() @@ -335,7 +335,7 @@ pub(super) fn discard_window_from( } } -fn record_rejected_trace( +pub(super) fn record_rejected_trace( step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, trace_token: Option, disposition: CandidateTraceDisposition, diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/evaluator.rs b/crates/solverforge-solver/src/phase/localsearch/phase/evaluator.rs new file mode 100644 index 00000000..e000d4cb --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/phase/evaluator.rs @@ -0,0 +1,158 @@ +//! The evaluation strategy of one local-search phase, chosen once per phase: +//! the one-evaluator loop, or parallel windows across evaluator replicas. + +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::Director; + +use crate::heuristic::r#move::Move; +use crate::heuristic::selector::move_selector::{MoveCandidateRef, ResourceMoveCursor}; +use crate::phase::localsearch::parallel::EvaluatorPool; +use crate::phase::localsearch::{Acceptor, LocalSearchForager}; +use crate::scope::{ProgressCallback, SolverScope, StepScope}; + +use super::candidates::{evaluate_candidate_windows, CandidateLoopState}; +use super::sequential; + +/* How a phase evaluates each step's candidates. Each implementation is its +own monomorphized step loop; the phase selects one before its first step. +*/ +pub(crate) trait StepEvaluator +where + S: PlanningSolution, + D: Director, + M: Move, +{ + // Brings the evaluators to the committed working solution. + fn step_started(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>) + where + BestCb: ProgressCallback; + + #[allow(clippy::too_many_arguments)] + fn evaluate_candidates( + &mut self, + cursor: &mut C, + resources: &mut Resources, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + last_step_score: S::Score, + acceptor: &mut A, + forager: &mut Fo, + requires_move_signatures: bool, + state: CandidateLoopState, + ) -> CandidateLoopState + where + BestCb: ProgressCallback, + C: ResourceMoveCursor, + A: Acceptor, + Fo: LocalSearchForager; + + // Applies the selected move ahead of its owned commit on the main director. + fn step_selected(&mut self, selected: &MoveCandidateRef<'_, S, M>, score: S::Score); + + // Records the working-solution generation after the commit. + fn step_committed(&mut self, generation: u64); +} + +/// One evaluator: the main director runs every trial in candidate order. +pub(crate) struct SequentialEvaluator; + +impl StepEvaluator for SequentialEvaluator +where + S: PlanningSolution, + D: Director, + M: Move, +{ + #[inline] + fn step_started(&mut self, _solver_scope: &SolverScope<'_, S, D, BestCb>) + where + BestCb: ProgressCallback, + { + } + + #[inline] + fn evaluate_candidates( + &mut self, + cursor: &mut C, + resources: &mut Resources, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + last_step_score: S::Score, + acceptor: &mut A, + forager: &mut Fo, + requires_move_signatures: bool, + state: CandidateLoopState, + ) -> CandidateLoopState + where + BestCb: ProgressCallback, + C: ResourceMoveCursor, + A: Acceptor, + Fo: LocalSearchForager, + { + sequential::evaluate_candidates( + cursor, + resources, + step_scope, + last_step_score, + acceptor, + forager, + requires_move_signatures, + state, + ) + } + + #[inline] + fn step_selected(&mut self, _selected: &MoveCandidateRef<'_, S, M>, _score: S::Score) {} + + #[inline] + fn step_committed(&mut self, _generation: u64) {} +} + +impl StepEvaluator for EvaluatorPool +where + S: PlanningSolution, + D: Director, + M: Move, +{ + fn step_started(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>) + where + BestCb: ProgressCallback, + { + self.sync(solver_scope); + } + + fn evaluate_candidates( + &mut self, + cursor: &mut C, + resources: &mut Resources, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + last_step_score: S::Score, + acceptor: &mut A, + forager: &mut Fo, + requires_move_signatures: bool, + state: CandidateLoopState, + ) -> CandidateLoopState + where + BestCb: ProgressCallback, + C: ResourceMoveCursor, + A: Acceptor, + Fo: LocalSearchForager, + { + evaluate_candidate_windows( + cursor, + resources, + step_scope, + last_step_score, + acceptor, + forager, + requires_move_signatures, + self, + state, + ) + } + + fn step_selected(&mut self, selected: &MoveCandidateRef<'_, S, M>, score: S::Score) { + self.advance(selected, score); + } + + fn step_committed(&mut self, generation: u64) { + self.mark_synced(generation); + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs new file mode 100644 index 00000000..b762772a --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs @@ -0,0 +1,289 @@ +//! The one-evaluator candidate loop: pull, check, trial and decide one +//! candidate at a time on the main director. + +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::Score; +use solverforge_scoring::Director; +use tracing::trace; + +use crate::heuristic::r#move::Move; +use crate::heuristic::selector::move_selector::{MoveCandidateUndo, ResourceMoveCursor}; +use crate::phase::control::{ + should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, +}; +use crate::phase::localsearch::evaluation::{ + evaluate_candidate, record_evaluated_move, CandidateEvaluation, +}; +use crate::phase::localsearch::{Acceptor, ForagerDecision, LocalSearchForager}; +use crate::scope::{ProgressCallback, StepScope}; +use crate::stats::{CandidateTraceDisposition, CandidateTraceSource}; + +use super::candidates::CandidateLoopState; +use super::decision::{record_rejected_trace, PROGRESS_POLL_INTERVAL}; +use super::{candidate_selector_label, take_trace_token}; + +#[allow(clippy::too_many_arguments)] +pub(super) fn evaluate_candidates( + cursor: &mut C, + resources: &mut Resources, + step_scope: &mut StepScope<'_, '_, '_, S, D, BestCb>, + last_step_score: S::Score, + acceptor: &mut A, + forager: &mut Fo, + requires_move_signatures: bool, + mut state: CandidateLoopState, +) -> CandidateLoopState +where + S: PlanningSolution, + D: Director, + BestCb: ProgressCallback, + M: Move, + C: ResourceMoveCursor, + A: Acceptor, + Fo: LocalSearchForager, +{ + // The control poll reads the wall clock; forwarding every candidate + // iteration makes it measurable at scale (measured ~9% of wall before + // batching). The step loop therefore polls once per batch window; a + // stop stays latched, and per-evaluation interruption still runs on + // its every-64-evaluations schedule below. + // Retired undo journals are recycled across the step's trials so + // arbitrary-long journals do not grow the heap per trial. + let mut journal_pool: Vec> = Vec::new(); + let mut control_polls = 0usize; + let mut control_latched = false; + while !forager.is_quit_early() { + if state.interrupted { + break; + } + control_polls += 1; + if !control_latched && control_polls.is_multiple_of(CONTROL_POLL_INTERVAL) { + control_latched = should_interrupt_before_candidate(step_scope) + || step_scope + .phase_scope_mut() + .solver_scope_mut() + .should_terminate(); + } + if control_latched { + state.interrupted = true; + break; + } + + let generation_started = std::time::Instant::now(); + let Some(candidate_id) = + cursor.next_candidate_with_resources(step_scope.score_director_mut(), resources) + else { + break; + }; + let selector_index = cursor.selector_index(candidate_id); + let mov = cursor + .candidate(candidate_id) + .expect("discovered candidate id must remain borrowable"); + let move_label = mov.telemetry_label(); + let selector_label = selector_index.map(|_| candidate_selector_label(&mov)); + let trace_token = step_scope.phase_scope_mut().record_candidate_pull( + CandidateTraceSource::LocalSearch, + selector_index, + candidate_id.index(), + None, + &mov, + ); + let generation_elapsed = generation_started.elapsed(); + state.generated_moves += 1; + step_scope + .phase_scope_mut() + .record_move_kind_generated(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_generated_move_with_label( + selector_index, + selector_label.as_deref().unwrap_or("selector"), + generation_elapsed, + ); + } else { + step_scope + .phase_scope_mut() + .record_generated_move(generation_elapsed); + } + if step_scope.progress_polling_required() + && state.generated_moves.is_multiple_of(PROGRESS_POLL_INTERVAL) + { + step_scope.phase_scope_mut().report_progress_if_due(); + } + + if should_interrupt_before_evaluation(step_scope) + || step_scope + .phase_scope_mut() + .solver_scope_mut() + .should_terminate() + { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::InterruptedBeforeEvaluation, + ); + } + state.interrupted = true; + break; + } + state.evaluated_moves += 1; + + let evaluation_started = std::time::Instant::now(); + let move_score = match evaluate_candidate( + &mov, + step_scope, + last_step_score, + selector_index, + evaluation_started, + &mut journal_pool, + ) { + CandidateEvaluation::Scored(score) => { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::Evaluated, + ); + } + step_scope + .phase_scope_mut() + .record_move_kind_evaluated(move_label, score.compare(&last_step_score)); + score + } + CandidateEvaluation::NotDoable => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::NotDoable, + ); + assert!(cursor.release_candidate(candidate_id)); + continue; + } + CandidateEvaluation::RejectedByHardImprovement(_) => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::RejectedByHardImprovement, + ); + assert!(cursor.release_candidate(candidate_id)); + continue; + } + CandidateEvaluation::RejectedByScoreImprovement(_) => { + record_rejected_trace( + step_scope, + trace_token, + CandidateTraceDisposition::RejectedByScoreImprovement, + ); + assert!(cursor.release_candidate(candidate_id)); + continue; + } + }; + // Moves that own a prebuilt signature lend it; only dynamic builders + // produce one, and it lives for this decision alone. + let built_signature; + let move_signature = if requires_move_signatures { + match mov.borrowed_tabu_signature() { + Some(borrowed) => Some(borrowed), + None => { + built_signature = mov.tabu_signature(step_scope.score_director()); + Some(&built_signature) + } + } + } else { + None + }; + + let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature); + + record_evaluated_move(step_scope, selector_index, evaluation_started); + if accepted { + step_scope + .phase_scope_mut() + .record_move_kind_accepted(move_label); + state.accepted_labels.record(move_label); + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_accepted(selector_index); + } else { + step_scope.phase_scope_mut().record_move_accepted(); + } + state.accepted_moves += 1; + } else { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::AcceptorRejected, + ); + } + if let Some(selector_index) = selector_index { + step_scope + .phase_scope_mut() + .record_selector_move_acceptor_rejected(selector_index); + } else { + step_scope.phase_scope_mut().record_move_acceptor_rejected(); + } + step_scope + .phase_scope_mut() + .record_move_kind_acceptor_rejected( + move_label, + move_score.compare(&last_step_score), + ); + } + + trace!( + event = "step", + step = step_scope.step_index(), + move_index = candidate_id.index(), + score = %move_score, + accepted = accepted, + ); + + if accepted { + match forager.add_move_index(candidate_id, move_score) { + ForagerDecision::Keep => { + if let Some(token) = trace_token { + state.accepted_trace_tokens.push((candidate_id, token)); + } + } + ForagerDecision::Release => { + if let Some(token) = trace_token { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + assert!(cursor.release_candidate(candidate_id)); + } + ForagerDecision::Replace(replaced_id) => { + if let Some(token) = + take_trace_token(&mut state.accepted_trace_tokens, replaced_id) + { + step_scope + .phase_scope_mut() + .record_candidate_trace_disposition( + token, + CandidateTraceDisposition::ForagerIgnored, + ); + } + if let Some(token) = trace_token { + state.accepted_trace_tokens.push((candidate_id, token)); + } + assert!(cursor.release_candidate(replaced_id)); + } + } + } else { + assert!(cursor.release_candidate(candidate_id)); + } + } + + state +} diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs index 08d1e555..134fc785 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs @@ -13,12 +13,12 @@ use crate::phase::control::{ settle_search_interrupt, should_interrupt_after_step, should_interrupt_before_candidate, StepInterrupt, }; -use crate::phase::localsearch::parallel::EvaluatorPool; use crate::phase::localsearch::{Acceptor, LocalSearchForager, MoveCursorSource}; use crate::scope::{PendingControl, PhaseScope, ProgressCallback, StepScope}; use crate::stats::{AppliedMoveTelemetry, CandidateTraceDisposition}; -use super::candidates::{evaluate_candidates, CandidateLoopState}; +use super::candidates::CandidateLoopState; +use super::evaluator::StepEvaluator; use super::take_trace_token; pub(super) enum StepOutcome { @@ -28,14 +28,14 @@ pub(super) enum StepOutcome { } #[allow(clippy::too_many_arguments)] -pub(super) fn execute_step( +pub(super) fn execute_step( move_source: &mut Source, resources: &mut Source::Resources, acceptor: &mut A, forager: &mut Fo, phase_scope: &mut PhaseScope<'_, '_, S, D, BestCb>, last_step_score: &mut S::Score, - evaluators: &mut EvaluatorPool, + evaluator: &mut E, ) -> StepOutcome where S: PlanningSolution, @@ -45,9 +45,10 @@ where Fo: LocalSearchForager, D: Director, BestCb: ProgressCallback, + E: StepEvaluator, { let mut step_scope = StepScope::new(phase_scope); - evaluators.sync(step_scope.phase_scope().solver_scope()); + evaluator.step_started(step_scope.phase_scope().solver_scope()); /* Reset forager and acceptor for this step. Pass best and last-step scores so foragers that implement @@ -86,7 +87,7 @@ where evaluated_moves, accepted_labels, mut accepted_trace_tokens, - } = evaluate_candidates( + } = evaluator.evaluate_candidates( &mut cursor, resources, &mut step_scope, @@ -94,7 +95,6 @@ where acceptor, forager, requires_move_signatures, - evaluators, CandidateLoopState::new(interrupted), ); @@ -143,11 +143,11 @@ where }; } let previous_score = *last_step_score; - evaluators.advance(&selected_move, selected_score); + evaluator.step_selected(&selected_move, selected_score); step_scope.apply_committed_change(|score_director| { cursor.apply_owned_candidate(selected_index, score_director); }); - evaluators.mark_synced( + evaluator.step_committed( step_scope .phase_scope() .solver_scope() diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs index 4a45ab0b..38d98c48 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs @@ -338,7 +338,7 @@ where } }; - record_evaluated_move(step_scope, selector_index, evaluation_started.elapsed()); + record_evaluated_move(step_scope, selector_index, evaluation_started); progress.record_evaluated(); progress.maybe_report(step_scope); From 844651a644dfdc904057b1acc9e4f176cde8b2fd Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 21:57:05 +0100 Subject: [PATCH 16/30] refactor(solver)!: lend tabu signatures through one Cow-returning method `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(..)`. --- crates/solverforge-solver/WIREFRAME.md | 2 +- .../selector/types/composite/cartesian.rs | 2 +- .../src/descriptor/move_types.rs | 1 + .../src/descriptor/move_types/change.rs | 6 +- .../descriptor/move_types/pillar_change.rs | 6 +- .../src/descriptor/move_types/pillar_swap.rs | 6 +- .../descriptor/move_types/ruin_recreate.rs | 4 +- .../src/descriptor/move_types/swap.rs | 6 +- .../src/descriptor/move_types/union.rs | 2 +- .../tests/mod/selectors/cartesian.rs | 3 +- .../src/heuristic/move/change.rs | 41 +++--- .../src/heuristic/move/composite.rs | 17 +-- .../src/heuristic/move/compound_scalar.rs | 11 +- .../src/heuristic/move/conflict_repair.rs | 3 +- .../src/heuristic/move/dynamic_list_change.rs | 9 +- .../heuristic/move/dynamic_scalar_change.rs | 41 +++--- .../src/heuristic/move/dynamic_scalar_swap.rs | 23 +-- .../src/heuristic/move/k_opt.rs | 7 +- .../src/heuristic/move/list_change.rs | 9 +- .../src/heuristic/move/list_multi_swap.rs | 10 +- .../src/heuristic/move/list_permute.rs | 7 +- .../src/heuristic/move/list_reverse.rs | 9 +- .../src/heuristic/move/list_ruin.rs | 7 +- .../src/heuristic/move/list_swap.rs | 9 +- .../src/heuristic/move/list_union.rs | 17 +-- .../src/heuristic/move/pillar_change.rs | 11 +- .../src/heuristic/move/pillar_swap.rs | 17 ++- .../src/heuristic/move/ruin.rs | 7 +- .../src/heuristic/move/ruin_recreate.rs | 7 +- .../src/heuristic/move/runtime_compound.rs | 11 +- .../src/heuristic/move/scalar_union.rs | 16 +- .../src/heuristic/move/sublist_change.rs | 9 +- .../src/heuristic/move/sublist_swap.rs | 9 +- .../src/heuristic/move/swap.rs | 23 +-- .../src/heuristic/move/tests/mod.rs | 1 + .../src/heuristic/move/tests/swap.rs | 5 +- .../move/tests/tabu_signature_lending.rs | 139 ++++++++++++++++++ .../src/heuristic/move/traits.rs | 16 +- .../selector/decorator/cartesian_product.rs | 16 +- .../decorator/cartesian_product/tests.rs | 8 +- .../selector/decorator/limited/tests.rs | 6 +- .../selector/decorator/vec_union/tests.rs | 6 +- .../src/heuristic/selector/move_selector.rs | 1 + .../selector/move_selector/borrowed.rs | 4 +- .../selector/scalar_neighborhood/move.rs | 5 +- .../heuristic/selector/tests/move_selector.rs | 7 +- .../mod_tests_integration/prompt_support.rs | 7 +- .../src/phase/construction/phase/tests.rs | 1 + .../phase/construction/phase/tests/support.rs | 9 +- .../phase/construction/runtime_slots/moves.rs | 51 ++++--- .../src/phase/hard_delta_phase_tests.rs | 7 +- .../src/phase/localsearch/phase/decision.rs | 20 +-- .../src/phase/localsearch/phase/sequential.rs | 21 +-- .../src/phase/localsearch/phase/step.rs | 11 +- .../phase/localsearch/phase/tests/common.rs | 1 + .../localsearch/phase/tests/selectors.rs | 34 ++--- .../src/phase/localsearch/vnd/tests.rs | 16 +- .../compiler/executor/list_leaf/move.rs | 7 +- .../compiler/executor/local_search/move.rs | 3 +- 59 files changed, 478 insertions(+), 292 deletions(-) create mode 100644 crates/solverforge-solver/src/heuristic/move/tests/tabu_signature_lending.rs diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index 1621b13a..eac76199 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -431,7 +431,7 @@ Requires: `Send + Sync + Debug`. | `telemetry_label` | `fn(&self) -> &'static str` (default `"move"`) | | `requires_hard_improvement` | `fn(&self) -> bool` | | `requires_score_improvement` | `fn(&self) -> bool` (default `false`) | -| `tabu_signature` | `fn>(&self, score_director: &D) -> MoveTabuSignature` | +| `tabu_signature` | `fn>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature>` — moves that own a prebuilt signature lend it (`Cow::Borrowed`); wrappers forward it unchanged | | `candidate_trace_identity` | `fn(&self) -> Option` (default `None`) | | `for_each_affected_entity` | `fn)>(&self, visitor: &mut V)` — concrete visitor, monomorphized; implementations must report every mutated entity, including both pillar halves for pillar swaps | diff --git a/crates/solverforge-solver/src/builder/selector/types/composite/cartesian.rs b/crates/solverforge-solver/src/builder/selector/types/composite/cartesian.rs index f8ad0fe1..bb26beb4 100644 --- a/crates/solverforge-solver/src/builder/selector/types/composite/cartesian.rs +++ b/crates/solverforge-solver/src/builder/selector/types/composite/cartesian.rs @@ -278,7 +278,7 @@ where .expect("left cartesian candidate must remain valid"); ( left.is_doable_with_working_preview(director), - left.tabu_signature(director), + left.tabu_signature(director).into_owned(), ) }; if !left_is_doable { diff --git a/crates/solverforge-solver/src/descriptor/move_types.rs b/crates/solverforge-solver/src/descriptor/move_types.rs index 317de53a..6ca02c1d 100644 --- a/crates/solverforge-solver/src/descriptor/move_types.rs +++ b/crates/solverforge-solver/src/descriptor/move_types.rs @@ -1,4 +1,5 @@ use std::any::Any; +use std::borrow::Cow; use std::fmt::{self, Debug}; use std::marker::PhantomData; diff --git a/crates/solverforge-solver/src/descriptor/move_types/change.rs b/crates/solverforge-solver/src/descriptor/move_types/change.rs index 4e71aa3c..d32e350a 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/change.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/change.rs @@ -100,7 +100,7 @@ where self.binding.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let current = self.current_value(score_director.working_solution()); let from_id = encode_option_usize(current); let to_id = encode_option_usize(self.to_value); @@ -108,7 +108,7 @@ where let variable_id = hash_str(self.binding.variable_name); let scope = MoveTabuScope::new(self.binding.descriptor_index, self.binding.variable_name); - MoveTabuSignature::new( + Cow::Owned(MoveTabuSignature::new( scope, smallvec![ encode_usize(self.binding.descriptor_index), @@ -126,6 +126,6 @@ where ], ) .with_entity_tokens([scope.entity_token(entity_id)]) - .with_destination_value_tokens([scope.value_token(to_id)]) + .with_destination_value_tokens([scope.value_token(to_id)])) } } diff --git a/crates/solverforge-solver/src/descriptor/move_types/pillar_change.rs b/crates/solverforge-solver/src/descriptor/move_types/pillar_change.rs index b4987e1a..0614c83a 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/pillar_change.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/pillar_change.rs @@ -135,7 +135,7 @@ where self.binding.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let from_value = self.entity_indices.first().and_then(|&entity_index| { self.current_value(score_director.working_solution(), entity_index) }); @@ -171,8 +171,8 @@ where ]; undo_move_id.extend(entity_ids.iter().copied()); - MoveTabuSignature::new(scope, move_id, undo_move_id) + Cow::Owned(MoveTabuSignature::new(scope, move_id, undo_move_id) .with_entity_tokens(entity_tokens) - .with_destination_value_tokens([scope.value_token(to_id)]) + .with_destination_value_tokens([scope.value_token(to_id)])) } } diff --git a/crates/solverforge-solver/src/descriptor/move_types/pillar_swap.rs b/crates/solverforge-solver/src/descriptor/move_types/pillar_swap.rs index b83a0900..bec90c9b 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/pillar_swap.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/pillar_swap.rs @@ -201,7 +201,7 @@ where self.binding.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let left_value = self.left_indices.first().and_then(|&entity_index| { self.current_value(score_director.working_solution(), entity_index) }); @@ -228,11 +228,11 @@ where let mut move_id = scoped_move_identity(scope, TABU_OP_PILLAR_SWAP, std::iter::empty()); append_canonical_usize_slice_pair(&mut move_id, &self.left_indices, &self.right_indices); - MoveTabuSignature::new_self_undoing(scope, move_id) + Cow::Owned(MoveTabuSignature::new_self_undoing(scope, move_id) .with_entity_tokens(entity_tokens) .with_destination_value_tokens([ scope.value_token(right_id), scope.value_token(left_id), - ]) + ])) } } diff --git a/crates/solverforge-solver/src/descriptor/move_types/ruin_recreate.rs b/crates/solverforge-solver/src/descriptor/move_types/ruin_recreate.rs index 36442cea..b86ccf87 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/ruin_recreate.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/ruin_recreate.rs @@ -244,7 +244,7 @@ where self.binding.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let variable_id = hash_str(self.binding.variable_name); let heuristic_id = match self.recreate_heuristic_type { RecreateHeuristicType::FirstFit => hash_str("first_fit"), @@ -277,6 +277,6 @@ where undo_move_id.push(encode_option_usize(current)); } - MoveTabuSignature::new(scope, move_id, undo_move_id).with_entity_tokens(entity_tokens) + Cow::Owned(MoveTabuSignature::new(scope, move_id, undo_move_id).with_entity_tokens(entity_tokens)) } } diff --git a/crates/solverforge-solver/src/descriptor/move_types/swap.rs b/crates/solverforge-solver/src/descriptor/move_types/swap.rs index b4f903ab..70c8bef5 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/swap.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/swap.rs @@ -148,7 +148,7 @@ where self.binding.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let left_val = self.current_value(score_director.working_solution(), self.left_entity_index); let right_val = @@ -165,7 +165,7 @@ where entity_pair.into_iter().map(|(entity_id, _)| entity_id), ); - MoveTabuSignature::new_self_undoing(scope, move_id) + Cow::Owned(MoveTabuSignature::new_self_undoing(scope, move_id) .with_entity_tokens([ scope.entity_token(left_entity_id), scope.entity_token(right_entity_id), @@ -173,6 +173,6 @@ where .with_destination_value_tokens([ scope.value_token(right_id), scope.value_token(left_id), - ]) + ])) } } diff --git a/crates/solverforge-solver/src/descriptor/move_types/union.rs b/crates/solverforge-solver/src/descriptor/move_types/union.rs index d2e265a3..721b8045 100644 --- a/crates/solverforge-solver/src/descriptor/move_types/union.rs +++ b/crates/solverforge-solver/src/descriptor/move_types/union.rs @@ -131,7 +131,7 @@ where } } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { match self { Self::Change(m) => m.tabu_signature(score_director), Self::Swap(m) => m.tabu_signature(score_director), diff --git a/crates/solverforge-solver/src/descriptor/tests/mod/selectors/cartesian.rs b/crates/solverforge-solver/src/descriptor/tests/mod/selectors/cartesian.rs index 77c1742d..df8819d1 100644 --- a/crates/solverforge-solver/src/descriptor/tests/mod/selectors/cartesian.rs +++ b/crates/solverforge-solver/src/descriptor/tests/mod/selectors/cartesian.rs @@ -55,7 +55,8 @@ fn descriptor_cartesian_builds_composite_moves() { let signature = cursor .candidate(indices[0]) .expect("descriptor cartesian candidate must remain valid") - .tabu_signature(&director); + .tabu_signature(&director) + .into_owned(); assert!(!signature.move_id.is_empty()); assert!(!signature.entity_tokens.is_empty()); } diff --git a/crates/solverforge-solver/src/heuristic/move/change.rs b/crates/solverforge-solver/src/heuristic/move/change.rs index 533c6efb..9e9cea28 100644 --- a/crates/solverforge-solver/src/heuristic/move/change.rs +++ b/crates/solverforge-solver/src/heuristic/move/change.rs @@ -9,6 +9,7 @@ This move stores concrete function pointers that operate directly on the solution. No `Arc`, no `Box`, no `downcast_ref`. */ +use std::borrow::Cow; use std::fmt::Debug; use smallvec::smallvec; @@ -186,7 +187,7 @@ where self.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let current = (self.getter)( score_director.working_solution(), self.entity_index, @@ -198,24 +199,26 @@ where let variable_id = hash_str(self.variable_name); let scope = MoveTabuScope::new(self.descriptor_index, self.variable_name); - MoveTabuSignature::new( - scope, - smallvec![ - encode_usize(self.descriptor_index), - variable_id, - entity_id, - from_id, - to_id - ], - smallvec![ - encode_usize(self.descriptor_index), - variable_id, - entity_id, - to_id, - from_id - ], + Cow::Owned( + MoveTabuSignature::new( + scope, + smallvec![ + encode_usize(self.descriptor_index), + variable_id, + entity_id, + from_id, + to_id + ], + smallvec![ + encode_usize(self.descriptor_index), + variable_id, + entity_id, + to_id, + from_id + ], + ) + .with_entity_tokens([scope.entity_token(entity_id)]) + .with_destination_value_tokens([scope.value_token(to_id)]), ) - .with_entity_tokens([scope.entity_token(entity_id)]) - .with_destination_value_tokens([scope.value_token(to_id)]) } } diff --git a/crates/solverforge-solver/src/heuristic/move/composite.rs b/crates/solverforge-solver/src/heuristic/move/composite.rs index 173f00ec..86ff275f 100644 --- a/crates/solverforge-solver/src/heuristic/move/composite.rs +++ b/crates/solverforge-solver/src/heuristic/move/composite.rs @@ -8,6 +8,7 @@ live in their respective arenas - CompositeMove just references them. No cloning, no boxing - just concrete arena indices. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -321,12 +322,8 @@ where self.first.requires_score_improvement() || self.second.requires_score_improvement() } - fn borrowed_tabu_signature(&self) -> Option<&MoveTabuSignature> { - Some(self.tabu_signature) - } - - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { - self.tabu_signature.clone() + fn tabu_signature>(&self, _score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Borrowed(self.tabu_signature) } fn candidate_trace_identity(&self) -> Option { @@ -433,12 +430,8 @@ where || self.second_move().requires_score_improvement() } - fn borrowed_tabu_signature(&self) -> Option<&MoveTabuSignature> { - Some(&self.tabu_signature) - } - - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { - self.tabu_signature.clone() + fn tabu_signature>(&self, _score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Borrowed(&self.tabu_signature) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/compound_scalar.rs b/crates/solverforge-solver/src/heuristic/move/compound_scalar.rs index a0e02ebb..623ee35f 100644 --- a/crates/solverforge-solver/src/heuristic/move/compound_scalar.rs +++ b/crates/solverforge-solver/src/heuristic/move/compound_scalar.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{self, Debug}; use smallvec::{smallvec, SmallVec}; @@ -319,7 +320,7 @@ where self.require_hard_improvement } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let scope = MoveTabuScope::new(self.descriptor_index(), self.variable_label); let mut move_id = smallvec![hash_str(self.reason)]; let mut undo_move_id = smallvec![hash_str(self.reason)]; @@ -341,9 +342,11 @@ where destination_tokens.push(edit_scope.value_token(to)); } - MoveTabuSignature::new(scope, move_id, undo_move_id) - .with_entity_tokens(entity_tokens) - .with_destination_value_tokens(destination_tokens) + Cow::Owned( + MoveTabuSignature::new(scope, move_id, undo_move_id) + .with_entity_tokens(entity_tokens) + .with_destination_value_tokens(destination_tokens), + ) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/conflict_repair.rs b/crates/solverforge-solver/src/heuristic/move/conflict_repair.rs index 59c790fb..8cee750b 100644 --- a/crates/solverforge-solver/src/heuristic/move/conflict_repair.rs +++ b/crates/solverforge-solver/src/heuristic/move/conflict_repair.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{self, Debug}; use solverforge_core::domain::PlanningSolution; @@ -101,7 +102,7 @@ where self.compound.reason() } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { self.compound.tabu_signature(score_director) } diff --git a/crates/solverforge-solver/src/heuristic/move/dynamic_list_change.rs b/crates/solverforge-solver/src/heuristic/move/dynamic_list_change.rs index b4df52b6..72da2cb4 100644 --- a/crates/solverforge-solver/src/heuristic/move/dynamic_list_change.rs +++ b/crates/solverforge-solver/src/heuristic/move/dynamic_list_change.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::{DynamicListVariableSlot, PlanningSolution}; @@ -116,8 +117,12 @@ where "dynamic_list_change" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - change_tabu_signature(&self.slot, self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(change_tabu_signature( + &self.slot, + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_change.rs b/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_change.rs index a73c606e..c37e00b7 100644 --- a/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_change.rs +++ b/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_change.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Debug; use smallvec::smallvec; @@ -112,7 +113,7 @@ where "dynamic_scalar_change" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let current = self .slot .current_value(score_director.working_solution(), self.entity_index); @@ -122,25 +123,27 @@ where let variable_id = hash_str(self.slot.variable_name); let scope = MoveTabuScope::new(self.slot.descriptor_index(), self.slot.variable_name); - MoveTabuSignature::new( - scope, - smallvec![ - encode_usize(self.slot.descriptor_index()), - variable_id, - entity_id, - from_id, - to_id - ], - smallvec![ - encode_usize(self.slot.descriptor_index()), - variable_id, - entity_id, - to_id, - from_id - ], + Cow::Owned( + MoveTabuSignature::new( + scope, + smallvec![ + encode_usize(self.slot.descriptor_index()), + variable_id, + entity_id, + from_id, + to_id + ], + smallvec![ + encode_usize(self.slot.descriptor_index()), + variable_id, + entity_id, + to_id, + from_id + ], + ) + .with_entity_tokens([scope.entity_token(entity_id)]) + .with_destination_value_tokens([scope.value_token(to_id)]), ) - .with_entity_tokens([scope.entity_token(entity_id)]) - .with_destination_value_tokens([scope.value_token(to_id)]) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_swap.rs b/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_swap.rs index d5a82b43..7ca19b64 100644 --- a/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/dynamic_scalar_swap.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::{DynamicScalarVariableSlot, PlanningSolution}; @@ -138,7 +139,7 @@ where "dynamic_scalar_swap" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let solution = score_director.working_solution(); let left_value = self.slot.current_value(solution, self.left_entity_index); let right_value = self.slot.current_value(solution, self.right_entity_index); @@ -154,15 +155,17 @@ where entity_pair.into_iter().map(|(entity_id, _)| entity_id), ); - MoveTabuSignature::new_self_undoing(scope, move_id) - .with_entity_tokens([ - scope.entity_token(left_entity_id), - scope.entity_token(right_entity_id), - ]) - .with_destination_value_tokens([ - scope.value_token(right_id), - scope.value_token(left_id), - ]) + Cow::Owned( + MoveTabuSignature::new_self_undoing(scope, move_id) + .with_entity_tokens([ + scope.entity_token(left_entity_id), + scope.entity_token(right_entity_id), + ]) + .with_destination_value_tokens([ + scope.value_token(right_id), + scope.value_token(left_id), + ]), + ) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/k_opt.rs b/crates/solverforge-solver/src/heuristic/move/k_opt.rs index 144a3375..f40961dd 100644 --- a/crates/solverforge-solver/src/heuristic/move/k_opt.rs +++ b/crates/solverforge-solver/src/heuristic/move/k_opt.rs @@ -58,6 +58,7 @@ sublist_insert, ``` */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -370,14 +371,14 @@ where "k_opt" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - k_opt_tabu_signature( + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(k_opt_tabu_signature( &self.access(), self.cuts(), &self.reconnection, self.variable_id, self.entity_index, score_director, - ) + )) } } diff --git a/crates/solverforge-solver/src/heuristic/move/list_change.rs b/crates/solverforge-solver/src/heuristic/move/list_change.rs index 44cf8407..96ca8144 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_change.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_change.rs @@ -8,6 +8,7 @@ Essential for vehicle routing and scheduling problems. Uses concrete function pointers for list operations. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -233,8 +234,12 @@ where "list_change" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - change_tabu_signature(&self.access(), self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(change_tabu_signature( + &self.access(), + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/list_multi_swap.rs b/crates/solverforge-solver/src/heuristic/move/list_multi_swap.rs index 53800138..f6ef459d 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_multi_swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_multi_swap.rs @@ -1,5 +1,6 @@ //! Public static facade for canonical multi-list-swap mechanics. +use std::borrow::Cow; use std::fmt::Debug; use smallvec::SmallVec; @@ -107,7 +108,12 @@ where self.require_score_improvement } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - multi_swap_tabu_signature(&self.access, &self.swaps, &self.indices, score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(multi_swap_tabu_signature( + &self.access, + &self.swaps, + &self.indices, + score_director, + )) } } diff --git a/crates/solverforge-solver/src/heuristic/move/list_permute.rs b/crates/solverforge-solver/src/heuristic/move/list_permute.rs index f3b3383e..d14d0cd4 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_permute.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_permute.rs @@ -1,5 +1,6 @@ /* ListPermuteMove - permutes a contiguous window within a list variable. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -171,14 +172,14 @@ where "list_permute" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - permute_tabu_signature( + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(permute_tabu_signature( &self.access(), self.coordinates(), &self.permutation, &self.inverse_permutation, score_director, - ) + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/list_reverse.rs b/crates/solverforge-solver/src/heuristic/move/list_reverse.rs index 2e725d69..35f99460 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_reverse.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_reverse.rs @@ -8,6 +8,7 @@ TSP 2-opt optimization where reversing a tour segment can reduce distance. Uses concrete function pointers for list operations. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -198,8 +199,12 @@ where "list_reverse" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - reverse_tabu_signature(&self.access(), self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(reverse_tabu_signature( + &self.access(), + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/list_ruin.rs b/crates/solverforge-solver/src/heuristic/move/list_ruin.rs index 64a9feb3..dadf3e13 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_ruin.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_ruin.rs @@ -1,5 +1,6 @@ //! Public static facade for canonical list ruin-and-recreate mechanics. +use std::borrow::Cow; use std::fmt::Debug; use smallvec::SmallVec; @@ -202,13 +203,13 @@ where "list_ruin" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - ruin_tabu_signature( + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(ruin_tabu_signature( &self.access, &self.sources, &self.entity_indices, score_director, - ) + )) } } diff --git a/crates/solverforge-solver/src/heuristic/move/list_swap.rs b/crates/solverforge-solver/src/heuristic/move/list_swap.rs index 6f6bffca..9bdaccad 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_swap.rs @@ -8,6 +8,7 @@ Useful for TSP-style improvements and route optimization. Uses concrete function pointers for list operations. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -224,8 +225,12 @@ where "list_swap" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - swap_tabu_signature(&self.access(), self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(swap_tabu_signature( + &self.access(), + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/list_union.rs b/crates/solverforge-solver/src/heuristic/move/list_union.rs index 7910cb29..5dca3ec1 100644 --- a/crates/solverforge-solver/src/heuristic/move/list_union.rs +++ b/crates/solverforge-solver/src/heuristic/move/list_union.rs @@ -4,6 +4,7 @@ This allows local search to combine all list move types in a single arena without trait-object dispatch. */ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -288,21 +289,7 @@ where } } - fn borrowed_tabu_signature(&self) -> Option<&MoveTabuSignature> { - match self { - Self::ListChange(m) => m.borrowed_tabu_signature(), - Self::ListSwap(m) => m.borrowed_tabu_signature(), - Self::ListMultiSwap(m) => m.borrowed_tabu_signature(), - Self::ListPermute(m) => m.borrowed_tabu_signature(), - Self::SublistChange(m) => m.borrowed_tabu_signature(), - Self::SublistSwap(m) => m.borrowed_tabu_signature(), - Self::ListReverse(m) => m.borrowed_tabu_signature(), - Self::KOpt(m) => m.borrowed_tabu_signature(), - Self::ListRuin(m) => m.borrowed_tabu_signature(), - } - } - - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { match self { Self::ListChange(m) => m.tabu_signature(score_director), Self::ListSwap(m) => m.tabu_signature(score_director), diff --git a/crates/solverforge-solver/src/heuristic/move/pillar_change.rs b/crates/solverforge-solver/src/heuristic/move/pillar_change.rs index 2af6d072..46bacaca 100644 --- a/crates/solverforge-solver/src/heuristic/move/pillar_change.rs +++ b/crates/solverforge-solver/src/heuristic/move/pillar_change.rs @@ -9,6 +9,7 @@ PillarChangeMove uses concrete function pointers instead of `dyn Any` for comple compile-time type safety. No runtime type checks or downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use smallvec::{smallvec, SmallVec}; @@ -192,7 +193,7 @@ where self.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let from_value = self.entity_indices.first().and_then(|&idx| { (self.getter)(score_director.working_solution(), idx, self.variable_index) }); @@ -228,8 +229,10 @@ where ]; undo_move_id.extend(entity_ids.iter().copied()); - MoveTabuSignature::new(scope, move_id, undo_move_id) - .with_entity_tokens(entity_tokens) - .with_destination_value_tokens([scope.value_token(to_id)]) + Cow::Owned( + MoveTabuSignature::new(scope, move_id, undo_move_id) + .with_entity_tokens(entity_tokens) + .with_destination_value_tokens([scope.value_token(to_id)]), + ) } } diff --git a/crates/solverforge-solver/src/heuristic/move/pillar_swap.rs b/crates/solverforge-solver/src/heuristic/move/pillar_swap.rs index f75a7d21..b4dfc5cf 100644 --- a/crates/solverforge-solver/src/heuristic/move/pillar_swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/pillar_swap.rs @@ -9,6 +9,7 @@ PillarSwapMove uses concrete function pointers instead of `dyn Any` for complete compile-time type safety. No runtime type checks or downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use smallvec::SmallVec; @@ -229,7 +230,7 @@ where } } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let left_value = self.left_indices.first().and_then(|&idx| { (self.getter)(score_director.working_solution(), idx, self.variable_index) }); @@ -256,11 +257,13 @@ where let mut move_id = scoped_move_identity(scope, TABU_OP_PILLAR_SWAP, std::iter::empty()); append_canonical_usize_slice_pair(&mut move_id, &self.left_indices, &self.right_indices); - MoveTabuSignature::new_self_undoing(scope, move_id) - .with_entity_tokens(entity_tokens) - .with_destination_value_tokens([ - scope.value_token(right_id), - scope.value_token(left_id), - ]) + Cow::Owned( + MoveTabuSignature::new_self_undoing(scope, move_id) + .with_entity_tokens(entity_tokens) + .with_destination_value_tokens([ + scope.value_token(right_id), + scope.value_token(left_id), + ]), + ) } } diff --git a/crates/solverforge-solver/src/heuristic/move/ruin.rs b/crates/solverforge-solver/src/heuristic/move/ruin.rs index 1eccc250..17913944 100644 --- a/crates/solverforge-solver/src/heuristic/move/ruin.rs +++ b/crates/solverforge-solver/src/heuristic/move/ruin.rs @@ -9,6 +9,7 @@ Large Neighborhood Search (LNS) algorithms. Uses concrete function pointers for variable access. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use smallvec::SmallVec; @@ -212,7 +213,7 @@ where self.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let scope = MoveTabuScope::new(self.descriptor_index, self.variable_name); let entity_ids: SmallVec<[u64; 2]> = self .entity_indices @@ -238,6 +239,8 @@ where move_id.extend(entity_ids.iter().copied()); move_id.extend(value_ids.iter().copied()); - MoveTabuSignature::new_self_undoing(scope, move_id).with_entity_tokens(entity_tokens) + Cow::Owned( + MoveTabuSignature::new_self_undoing(scope, move_id).with_entity_tokens(entity_tokens), + ) } } diff --git a/crates/solverforge-solver/src/heuristic/move/ruin_recreate.rs b/crates/solverforge-solver/src/heuristic/move/ruin_recreate.rs index 0bf1858a..ea58e4f5 100644 --- a/crates/solverforge-solver/src/heuristic/move/ruin_recreate.rs +++ b/crates/solverforge-solver/src/heuristic/move/ruin_recreate.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{self, Debug}; use smallvec::{smallvec, SmallVec}; @@ -426,7 +427,7 @@ where self.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let variable_id = hash_str(self.variable_name); let heuristic_id = match self.recreate_heuristic_type { RecreateHeuristicType::FirstFit => hash_str("first_fit"), @@ -463,6 +464,8 @@ where undo_move_id.push(encode_option_usize(current)); } - MoveTabuSignature::new(scope, move_id, undo_move_id).with_entity_tokens(entity_tokens) + Cow::Owned( + MoveTabuSignature::new(scope, move_id, undo_move_id).with_entity_tokens(entity_tokens), + ) } } diff --git a/crates/solverforge-solver/src/heuristic/move/runtime_compound.rs b/crates/solverforge-solver/src/heuristic/move/runtime_compound.rs index 1361fbfa..30f10270 100644 --- a/crates/solverforge-solver/src/heuristic/move/runtime_compound.rs +++ b/crates/solverforge-solver/src/heuristic/move/runtime_compound.rs @@ -4,6 +4,7 @@ //! reason and carries the shared static/dynamic scalar access protocol. It //! never converts a host label into a leaked `&'static str`. +use std::borrow::Cow; use std::fmt::{self, Debug}; #[cfg(test)] @@ -210,7 +211,7 @@ where self.require_hard_improvement } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let first = self .edits .first() @@ -244,9 +245,11 @@ where } } - MoveTabuSignature::new(scope, move_id, undo_move_id) - .with_entity_tokens(entity_tokens) - .with_destination_value_tokens(destination_tokens) + Cow::Owned( + MoveTabuSignature::new(scope, move_id, undo_move_id) + .with_entity_tokens(entity_tokens) + .with_destination_value_tokens(destination_tokens), + ) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/scalar_union.rs b/crates/solverforge-solver/src/heuristic/move/scalar_union.rs index d434bf63..70101788 100644 --- a/crates/solverforge-solver/src/heuristic/move/scalar_union.rs +++ b/crates/solverforge-solver/src/heuristic/move/scalar_union.rs @@ -4,6 +4,7 @@ This allows local search to combine scalar change, swap, pillar, and ruin-recreate moves without trait-object dispatch. */ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -211,20 +212,7 @@ where } } - fn borrowed_tabu_signature(&self) -> Option<&MoveTabuSignature> { - match self { - Self::Change(m) => m.borrowed_tabu_signature(), - Self::Swap(m) => m.borrowed_tabu_signature(), - Self::PillarChange(m) => m.borrowed_tabu_signature(), - Self::PillarSwap(m) => m.borrowed_tabu_signature(), - Self::RuinRecreate(m) => m.borrowed_tabu_signature(), - Self::CompoundScalar(m) => m.borrowed_tabu_signature(), - Self::ConflictRepair(m) => m.borrowed_tabu_signature(), - Self::RuntimeCompound(m) => m.borrowed_tabu_signature(), - } - } - - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { match self { Self::Change(m) => m.tabu_signature(score_director), Self::Swap(m) => m.tabu_signature(score_director), diff --git a/crates/solverforge-solver/src/heuristic/move/sublist_change.rs b/crates/solverforge-solver/src/heuristic/move/sublist_change.rs index 608b8383..35e6bd8b 100644 --- a/crates/solverforge-solver/src/heuristic/move/sublist_change.rs +++ b/crates/solverforge-solver/src/heuristic/move/sublist_change.rs @@ -8,6 +8,7 @@ Essential for vehicle routing where multiple consecutive stops need relocation. Uses concrete function pointers for list operations. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -258,8 +259,12 @@ where "sublist_change" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - sublist_change_tabu_signature(&self.access(), self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(sublist_change_tabu_signature( + &self.access(), + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/sublist_swap.rs b/crates/solverforge-solver/src/heuristic/move/sublist_swap.rs index 6e3c07fc..04e22773 100644 --- a/crates/solverforge-solver/src/heuristic/move/sublist_swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/sublist_swap.rs @@ -8,6 +8,7 @@ where segments need to be swapped between vehicles. Uses concrete function pointers for list operations. No `dyn Any`, no downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; @@ -272,8 +273,12 @@ where "sublist_swap" } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - sublist_swap_tabu_signature(&self.access(), self.coordinates(), score_director) + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(sublist_swap_tabu_signature( + &self.access(), + self.coordinates(), + score_director, + )) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/swap.rs b/crates/solverforge-solver/src/heuristic/move/swap.rs index ef0773ab..1394123a 100644 --- a/crates/solverforge-solver/src/heuristic/move/swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/swap.rs @@ -9,6 +9,7 @@ SwapMove uses concrete function pointers instead of `dyn Any` for complete compile-time type safety. No runtime type checks or downcasting. */ +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -225,7 +226,7 @@ where self.variable_name } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let left_val = (self.getter)( score_director.working_solution(), self.left_entity_index, @@ -248,15 +249,17 @@ where entity_pair.into_iter().map(|(entity_id, _)| entity_id), ); - MoveTabuSignature::new_self_undoing(scope, move_id) - .with_entity_tokens([ - scope.entity_token(left_entity_id), - scope.entity_token(right_entity_id), - ]) - .with_destination_value_tokens([ - scope.value_token(right_id), - scope.value_token(left_id), - ]) + Cow::Owned( + MoveTabuSignature::new_self_undoing(scope, move_id) + .with_entity_tokens([ + scope.entity_token(left_entity_id), + scope.entity_token(right_entity_id), + ]) + .with_destination_value_tokens([ + scope.value_token(right_id), + scope.value_token(left_id), + ]), + ) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/move/tests/mod.rs b/crates/solverforge-solver/src/heuristic/move/tests/mod.rs index 9cb6b5d9..0f81af95 100644 --- a/crates/solverforge-solver/src/heuristic/move/tests/mod.rs +++ b/crates/solverforge-solver/src/heuristic/move/tests/mod.rs @@ -134,4 +134,5 @@ mod ruin; mod sublist_change; mod sublist_swap; mod swap; +mod tabu_signature_lending; mod transfer_payloads; diff --git a/crates/solverforge-solver/src/heuristic/move/tests/swap.rs b/crates/solverforge-solver/src/heuristic/move/tests/swap.rs index 57d16b0d..c217ae7f 100644 --- a/crates/solverforge-solver/src/heuristic/move/tests/swap.rs +++ b/crates/solverforge-solver/src/heuristic/move/tests/swap.rs @@ -171,7 +171,10 @@ fn swap_tabu_identity_is_direction_stable() { let mut acceptor = TabuSearchAcceptor::::new(TabuSearchPolicy::move_only(10)); acceptor.phase_started(&SoftScore::of(-10)); - acceptor.step_ended(&SoftScore::of(-9), Some(forward_signature.clone())); + acceptor.step_ended( + &SoftScore::of(-9), + Some(forward_signature.clone().into_owned()), + ); assert!(!acceptor.is_accepted( &SoftScore::of(-9), diff --git a/crates/solverforge-solver/src/heuristic/move/tests/tabu_signature_lending.rs b/crates/solverforge-solver/src/heuristic/move/tests/tabu_signature_lending.rs new file mode 100644 index 00000000..36d63247 --- /dev/null +++ b/crates/solverforge-solver/src/heuristic/move/tests/tabu_signature_lending.rs @@ -0,0 +1,139 @@ +//! A prebuilt composite signature is lent through every wrapper layer. +//! +//! Each assertion checks that the returned `Cow` borrows the composite's own +//! stored signature, so no layer rebuilds or copies it. + +use std::borrow::Cow; + +use crate::heuristic::r#move::metadata::MoveTabuScope; +use crate::heuristic::r#move::{ + ChangeMove, Move, MoveTabuSignature, ScalarMoveUnion, SequentialCompositeMove, + SequentialCompositeMoveRef, +}; +use crate::heuristic::selector::move_selector::MoveCandidateRef; +use crate::heuristic::selector::nearby_list_change::DefaultCrossEntityDistanceMeter; +use crate::runtime::compiler::executor::local_search::RuntimeNeighborhoodMove; +use crate::test_utils::{create_nqueens_director, get_queen_row, set_queen_row, NQueensSolution}; + +type QueenMove = ChangeMove; +type RuntimeMove = RuntimeNeighborhoodMove< + NQueensSolution, + usize, + DefaultCrossEntityDistanceMeter, + DefaultCrossEntityDistanceMeter, +>; + +fn queen_move(entity: usize, row: i64) -> QueenMove { + ChangeMove::new(entity, Some(row), get_queen_row, set_queen_row, 0, "row", 0) +} + +fn get_row(solution: &NQueensSolution, entity: usize, variable: usize) -> Option { + get_queen_row(solution, entity, variable).map(|value| value as usize) +} + +fn set_row(solution: &mut NQueensSolution, entity: usize, variable: usize, value: Option) { + set_queen_row(solution, entity, variable, value.map(|value| value as i64)); +} + +fn runtime_move(entity: usize, row: usize) -> RuntimeMove { + RuntimeMove::Grouped(ScalarMoveUnion::Change(ChangeMove::new( + entity, + Some(row), + get_row, + set_row, + 0, + "row", + 0, + ))) +} + +fn composite_signature() -> MoveTabuSignature { + MoveTabuSignature::new( + MoveTabuScope::new(0, "row"), + smallvec::smallvec![7], + smallvec::smallvec![8], + ) +} + +fn assert_lends(signature: Cow<'_, MoveTabuSignature>, stored: &MoveTabuSignature) { + match signature { + Cow::Borrowed(lent) => assert!( + std::ptr::eq(lent, stored), + "the lent signature must be the composite's stored signature" + ), + Cow::Owned(_) => panic!("a prebuilt composite signature must be lent, not rebuilt"), + } +} + +#[test] +fn owned_composite_lends_through_reference_and_candidate_wrappers() { + let director = create_nqueens_director(&[0, 1]); + let composite = SequentialCompositeMove::new( + queen_move(0, 2), + queen_move(1, 3), + 0, + smallvec::smallvec![0, 1], + "row", + composite_signature(), + ); + let stored = match composite.tabu_signature(&director) { + Cow::Borrowed(stored) => stored, + Cow::Owned(_) => panic!("an owned composite must lend its stored signature"), + }; + + let by_reference = &composite; + assert_lends(Move::tabu_signature(&by_reference, &director), stored); + let candidate = MoveCandidateRef::Borrowed(&composite); + assert_lends(candidate.tabu_signature(&director), stored); +} + +#[test] +fn borrowed_sequential_candidate_lends_the_cursor_signature() { + let director = create_nqueens_director(&[0, 1]); + let left = queen_move(0, 2); + let right = queen_move(1, 3); + let stored = composite_signature(); + let candidate = MoveCandidateRef::Sequential(SequentialCompositeMoveRef::new( + &left, + &right, + 0, + &[0, 1], + "row", + &stored, + false, + )); + + assert_lends(candidate.tabu_signature(&director), &stored); +} + +#[test] +fn compiled_runtime_sequential_move_lends_its_signature() { + let director = create_nqueens_director(&[0, 1]); + let selected = RuntimeMove::Sequential(SequentialCompositeMove::new( + runtime_move(0, 2), + runtime_move(1, 3), + 0, + smallvec::smallvec![0, 1], + "row", + composite_signature(), + )); + let RuntimeMove::Sequential(composite) = &selected else { + unreachable!("constructed as a sequential carrier") + }; + let stored = match composite.tabu_signature(&director) { + Cow::Borrowed(stored) => stored, + Cow::Owned(_) => panic!("an owned composite must lend its stored signature"), + }; + + assert_lends(selected.tabu_signature(&director), stored); +} + +#[test] +fn leaf_moves_build_an_owned_signature() { + let director = create_nqueens_director(&[0]); + + assert!(matches!( + queen_move(0, 2).tabu_signature(&director), + Cow::Owned(_) + )); +} diff --git a/crates/solverforge-solver/src/heuristic/move/traits.rs b/crates/solverforge-solver/src/heuristic/move/traits.rs index 0efb3093..21e9a75e 100644 --- a/crates/solverforge-solver/src/heuristic/move/traits.rs +++ b/crates/solverforge-solver/src/heuristic/move/traits.rs @@ -1,5 +1,6 @@ // Move trait definition. +use std::borrow::Cow; use std::fmt::Debug; use solverforge_core::domain::PlanningSolution; @@ -102,16 +103,11 @@ pub trait Move: Send + Sync + Debug { false } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature; - - /* Borrows this move's prebuilt signature instead of building a fresh one. - - Moves that own their completed signature (static compositions) return - `Some`. Dynamic builders return `None`, and callers fall back to - `tabu_signature`. */ - fn borrowed_tabu_signature(&self) -> Option<&MoveTabuSignature> { - None - } + /* This move's tabu signature. Moves that own a prebuilt signature + (static compositions) lend it as `Cow::Borrowed`; moves that derive one + from the working solution return `Cow::Owned`. Wrappers forward this + method, so a borrowed signature survives every layer of wrapping. */ + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature>; /// Returns the canonical identity used by opt-in candidate-pull tracing. /// diff --git a/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product.rs b/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product.rs index 8ef36357..915cfe1d 100644 --- a/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product.rs +++ b/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product.rs @@ -373,16 +373,14 @@ where None }; if let Some(entity_indices) = combined { + let tabu_signature = compose_sequential_tabu_signature( + "cartesian_product", + left_signature, + &signature, + ); let moved = ( active.right_cursor.take_candidate(right_id), - ( - entity_indices, - compose_sequential_tabu_signature( - "cartesian_product", - left_signature, - &signature, - ), - ), + (entity_indices, tabu_signature), ); break Some(moved); } @@ -455,7 +453,7 @@ where assert!(self.left_cursor.release_candidate(left_id)); continue; } - let left_signature = left.tabu_signature(director); + let left_signature = left.tabu_signature(director).into_owned(); let left = self.left_cursor.take_candidate(left_id); let left_undo = left.do_move(director); // Preview contract (mirrors SequentialPreviewDirector): the diff --git a/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product/tests.rs b/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product/tests.rs index 7f44b3f3..e9080be4 100644 --- a/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product/tests.rs +++ b/crates/solverforge-solver/src/heuristic/selector/decorator/cartesian_product/tests.rs @@ -512,7 +512,8 @@ fn cartesian_product_reverse_signature_matches_true_inverse_order() { let reverse_signature = reverse_cursor .candidate(reverse_index) .expect("reverse cartesian candidate must remain valid") - .tabu_signature(&director); + .tabu_signature(&director) + .into_owned(); assert_eq!(forward_signature.undo_id(), &reverse_signature.move_id); @@ -527,7 +528,10 @@ fn cartesian_product_reverse_signature_matches_true_inverse_order() { .validated(), ); acceptor.phase_started(&SoftScore::of(0)); - acceptor.step_ended(&SoftScore::of(0), Some(forward_signature.clone())); + acceptor.step_ended( + &SoftScore::of(0), + Some(forward_signature.clone().into_owned()), + ); assert!(!acceptor.is_accepted( &SoftScore::of(0), diff --git a/crates/solverforge-solver/src/heuristic/selector/decorator/limited/tests.rs b/crates/solverforge-solver/src/heuristic/selector/decorator/limited/tests.rs index 4aa0f199..89026ad9 100644 --- a/crates/solverforge-solver/src/heuristic/selector/decorator/limited/tests.rs +++ b/crates/solverforge-solver/src/heuristic/selector/decorator/limited/tests.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::Debug; use std::sync::{ atomic::{AtomicUsize, Ordering}, @@ -55,7 +56,10 @@ impl Move for TestMove { "test" } - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { + fn tabu_signature>( + &self, + _score_director: &D, + ) -> Cow<'_, MoveTabuSignature> { panic!("limited cursor test does not evaluate tabu signatures") } } diff --git a/crates/solverforge-solver/src/heuristic/selector/decorator/vec_union/tests.rs b/crates/solverforge-solver/src/heuristic/selector/decorator/vec_union/tests.rs index 43b38491..fcde6cfc 100644 --- a/crates/solverforge-solver/src/heuristic/selector/decorator/vec_union/tests.rs +++ b/crates/solverforge-solver/src/heuristic/selector/decorator/vec_union/tests.rs @@ -3,6 +3,7 @@ use solverforge_core::domain::SolutionDescriptor; use solverforge_core::score::SoftScore; use solverforge_scoring::Director; use std::any::TypeId; +use std::borrow::Cow; use std::cell::Cell; use super::*; @@ -112,7 +113,10 @@ impl Move for TestMove { "test" } - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { + fn tabu_signature>( + &self, + _score_director: &D, + ) -> Cow<'_, MoveTabuSignature> { panic!("vec union tests do not evaluate tabu signatures") } } diff --git a/crates/solverforge-solver/src/heuristic/selector/move_selector.rs b/crates/solverforge-solver/src/heuristic/selector/move_selector.rs index d770b5a1..8b58e5c4 100644 --- a/crates/solverforge-solver/src/heuristic/selector/move_selector.rs +++ b/crates/solverforge-solver/src/heuristic/selector/move_selector.rs @@ -5,6 +5,7 @@ The solver evaluates candidates by reference and only takes ownership of the selected move once the forager commits to an index. */ +use std::borrow::Cow; use std::fmt::Debug; use std::marker::PhantomData; diff --git a/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs b/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs index a666af32..1ee2b2ae 100644 --- a/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs +++ b/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs @@ -67,7 +67,7 @@ where fn tabu_signature>( &self, score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { (**self).tabu_signature(score_director) } @@ -278,7 +278,7 @@ where fn tabu_signature>( &self, score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { match self { Self::Borrowed(mov) => mov.tabu_signature(score_director), Self::Sequential(mov) => mov.tabu_signature(score_director), diff --git a/crates/solverforge-solver/src/heuristic/selector/scalar_neighborhood/move.rs b/crates/solverforge-solver/src/heuristic/selector/scalar_neighborhood/move.rs index acdc2098..401b96b5 100644 --- a/crates/solverforge-solver/src/heuristic/selector/scalar_neighborhood/move.rs +++ b/crates/solverforge-solver/src/heuristic/selector/scalar_neighborhood/move.rs @@ -4,6 +4,7 @@ mod metadata; use solverforge_core::domain::PlanningSolution; use solverforge_core::score::Score; use solverforge_scoring::Director; +use std::borrow::Cow; use smallvec::SmallVec; @@ -104,8 +105,8 @@ where } } - fn tabu_signature>(&self, director: &D) -> MoveTabuSignature { - metadata::tabu_signature(&self.recipe, director) + fn tabu_signature>(&self, director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(metadata::tabu_signature(&self.recipe, director)) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/heuristic/selector/tests/move_selector.rs b/crates/solverforge-solver/src/heuristic/selector/tests/move_selector.rs index a7a75bbe..03287044 100644 --- a/crates/solverforge-solver/src/heuristic/selector/tests/move_selector.rs +++ b/crates/solverforge-solver/src/heuristic/selector/tests/move_selector.rs @@ -6,6 +6,7 @@ use solverforge_core::domain::{ use solverforge_core::score::SoftScore; use solverforge_scoring::ScoreDirector; use std::any::TypeId; +use std::borrow::Cow; use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, @@ -123,13 +124,13 @@ impl Move for StoredMove { fn tabu_signature>( &self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "stored"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![1], smallvec::smallvec![1], - ) + )) } } diff --git a/crates/solverforge-solver/src/manager/mod_tests_integration/prompt_support.rs b/crates/solverforge-solver/src/manager/mod_tests_integration/prompt_support.rs index ffb26224..25308384 100644 --- a/crates/solverforge-solver/src/manager/mod_tests_integration/prompt_support.rs +++ b/crates/solverforge-solver/src/manager/mod_tests_integration/prompt_support.rs @@ -1,4 +1,5 @@ use std::any::TypeId; +use std::borrow::Cow; use std::time::Duration; use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; @@ -169,14 +170,14 @@ impl Move for NoOpMove { fn tabu_signature>( &self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "noop"); let identity = crate::heuristic::r#move::metadata::hash_str("prompt_support_noop_move"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![identity], smallvec::smallvec![identity], - ) + )) } } diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests.rs b/crates/solverforge-solver/src/phase/construction/phase/tests.rs index 53d46241..2a756da5 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use super::*; use std::any::TypeId; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests/support.rs b/crates/solverforge-solver/src/phase/construction/phase/tests/support.rs index 9662e0c1..a0e861f4 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests/support.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests/support.rs @@ -244,12 +244,11 @@ impl Move for ConstructionPauseMove { "value" } - fn tabu_signature>( - &self, + fn tabu_signature>(&self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "value"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![ crate::heuristic::r#move::metadata::hash_str("construction_pause_move"), @@ -261,7 +260,7 @@ impl Move for ConstructionPauseMove { self.entity_index as u64, self.value as u64, ], - ) + )) } } diff --git a/crates/solverforge-solver/src/phase/construction/runtime_slots/moves.rs b/crates/solverforge-solver/src/phase/construction/runtime_slots/moves.rs index 3f592300..9540fd12 100644 --- a/crates/solverforge-solver/src/phase/construction/runtime_slots/moves.rs +++ b/crates/solverforge-solver/src/phase/construction/runtime_slots/moves.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt; use smallvec::smallvec; @@ -120,7 +121,7 @@ where } } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { let current = self .slot .current_value(score_director.working_solution(), self.entity_index); @@ -129,25 +130,27 @@ where let entity = encode_usize(self.entity_index); let scope = MoveTabuScope::new(self.slot.descriptor_index(), self.slot.variable_name()); let variable = hash_str(self.slot.variable_name()); - MoveTabuSignature::new( - scope, - smallvec![ - encode_usize(self.slot.descriptor_index()), - variable, - entity, - from, - to - ], - smallvec![ - encode_usize(self.slot.descriptor_index()), - variable, - entity, - to, - from - ], + Cow::Owned( + MoveTabuSignature::new( + scope, + smallvec![ + encode_usize(self.slot.descriptor_index()), + variable, + entity, + from, + to + ], + smallvec![ + encode_usize(self.slot.descriptor_index()), + variable, + entity, + to, + from + ], + ) + .with_entity_tokens([scope.entity_token(entity)]) + .with_destination_value_tokens([scope.value_token(to)]), ) - .with_entity_tokens([scope.entity_token(entity)]) - .with_destination_value_tokens([scope.value_token(to)]) } fn candidate_trace_identity(&self) -> Option { @@ -277,7 +280,7 @@ where "runtime_list_insertion" } - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, _score_director: &D) -> Cow<'_, MoveTabuSignature> { let scope = MoveTabuScope::new(self.slot.descriptor_index(), self.slot.variable_name()); let source = encode_usize(self.source_index); let entity = encode_usize(self.entity_index); @@ -290,9 +293,11 @@ where entity, position ]; - MoveTabuSignature::new_self_undoing(scope, move_id) - .with_entity_tokens([scope.entity_token(entity)]) - .with_destination_value_tokens([scope.value_token(source)]) + Cow::Owned( + MoveTabuSignature::new_self_undoing(scope, move_id) + .with_entity_tokens([scope.entity_token(entity)]) + .with_destination_value_tokens([scope.value_token(source)]), + ) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs b/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs index 965fca60..365f8bab 100644 --- a/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs +++ b/crates/solverforge-solver/src/phase/hard_delta_phase_tests.rs @@ -1,4 +1,5 @@ use std::any::TypeId; +use std::borrow::Cow; use smallvec::smallvec; use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; @@ -197,12 +198,12 @@ impl Move for BendableRepairMove { fn tabu_signature>( &self, _score_director: &D, - ) -> MoveTabuSignature { - MoveTabuSignature::new( + ) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(MoveTabuSignature::new( MoveTabuScope::new(0, "bendable_repair_move"), smallvec![hash_str("bendable_repair_move")], smallvec![hash_str("bendable_repair_move")], - ) + )) } } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs index e3c2e293..0db6b89e 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -172,22 +172,12 @@ pub(super) fn consume_candidate( return; } }; - // Moves that own a prebuilt signature lend it; only dynamic builders - // produce one, and it lives for this decision alone. - let built_signature; - let move_signature = if requires_move_signatures { - match mov.borrowed_tabu_signature() { - Some(borrowed) => Some(borrowed), - None => { - built_signature = mov.tabu_signature(step_scope.score_director()); - Some(&built_signature) - } - } - } else { - None - }; + // Moves that own a prebuilt signature lend it; dynamic builders produce + // one that lives for this decision alone. + let move_signature = + requires_move_signatures.then(|| mov.tabu_signature(step_scope.score_director())); - let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature); + let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_deref()); record_evaluated_elapsed(step_scope, selector_index, elapsed); if accepted { diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs index b762772a..4cbebc0a 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs @@ -182,22 +182,13 @@ where continue; } }; - // Moves that own a prebuilt signature lend it; only dynamic builders - // produce one, and it lives for this decision alone. - let built_signature; - let move_signature = if requires_move_signatures { - match mov.borrowed_tabu_signature() { - Some(borrowed) => Some(borrowed), - None => { - built_signature = mov.tabu_signature(step_scope.score_director()); - Some(&built_signature) - } - } - } else { - None - }; + // Moves that own a prebuilt signature lend it; dynamic builders produce + // one that lives for this decision alone. + let move_signature = + requires_move_signatures.then(|| mov.tabu_signature(step_scope.score_director())); - let accepted = acceptor.is_accepted(&last_step_score, &move_score, move_signature); + let accepted = + acceptor.is_accepted(&last_step_score, &move_score, move_signature.as_deref()); record_evaluated_move(step_scope, selector_index, evaluation_started); if accepted { diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs index 134fc785..98c9003d 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs @@ -137,10 +137,13 @@ where .expect("selected candidate id must remain borrowable until commit"); let selected_move_label = selected_move.telemetry_label(); if requires_move_signatures { - accepted_move_signature = match selected_move.borrowed_tabu_signature() { - Some(borrowed) => Some(borrowed.clone()), - None => Some(selected_move.tabu_signature(step_scope.score_director())), - }; + // Tabu memory retains the signature past the commit, so it is + // taken by ownership; only a lent composite signature is copied. + accepted_move_signature = Some( + selected_move + .tabu_signature(step_scope.score_director()) + .into_owned(), + ); } let previous_score = *last_step_score; evaluator.step_selected(&selected_move, selected_score); diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/tests/common.rs b/crates/solverforge-solver/src/phase/localsearch/phase/tests/common.rs index b4733f1c..3654d36c 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/tests/common.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/tests/common.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use super::*; use std::any::TypeId; diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/tests/selectors.rs b/crates/solverforge-solver/src/phase/localsearch/phase/tests/selectors.rs index ca3e3d4e..bf8b163c 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/tests/selectors.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/tests/selectors.rs @@ -24,17 +24,16 @@ impl Move for NoopMove { "noop" } - fn tabu_signature>( - &self, + fn tabu_signature>(&self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "noop"); let identity = crate::heuristic::r#move::metadata::hash_str("phase_tests_noop_move"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![identity], smallvec::smallvec![identity], - ) + )) } } @@ -149,16 +148,15 @@ impl Move for ScoreFieldMove { "score" } - fn tabu_signature>( - &self, + fn tabu_signature>(&self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "score"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![self.0 as u64], smallvec::smallvec![self.0 as u64], - ) + )) } } @@ -196,11 +194,10 @@ impl Move for ScoreImprovementRequiredMove { true } - fn tabu_signature>( - &self, + fn tabu_signature>(&self, score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { - ScoreFieldMove(self.0).tabu_signature(score_director) + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { + Cow::Owned(ScoreFieldMove(self.0).tabu_signature(score_director).into_owned()) } } @@ -365,16 +362,15 @@ impl Move for CancelOnDoableMove { "score" } - fn tabu_signature>( - &self, + fn tabu_signature>(&self, _score_director: &D, - ) -> crate::heuristic::r#move::MoveTabuSignature { + ) -> Cow<'_, crate::heuristic::r#move::MoveTabuSignature> { let scope = crate::heuristic::r#move::metadata::MoveTabuScope::new(0, "score"); - crate::heuristic::r#move::MoveTabuSignature::new( + Cow::Owned(crate::heuristic::r#move::MoveTabuSignature::new( scope, smallvec::smallvec![self.score as u64], smallvec::smallvec![self.score as u64], - ) + )) } } diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs index 0f93055a..0ff0d329 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs @@ -1,4 +1,5 @@ use std::any::TypeId; +use std::borrow::Cow; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -129,12 +130,15 @@ impl Move for InterruptMove { "interrupt_move" } - fn tabu_signature>(&self, _score_director: &D) -> MoveTabuSignature { - MoveTabuSignature::new( + fn tabu_signature>( + &self, + _score_director: &D, + ) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(MoveTabuSignature::new( MoveTabuScope::new(0, "interrupt_move"), smallvec![hash_str("vnd_interrupt_move")], smallvec![hash_str("vnd_interrupt_move")], - ) + )) } } @@ -434,12 +438,12 @@ impl Move for HardRepairMove { fn tabu_signature>( &self, _score_director: &D, - ) -> MoveTabuSignature { - MoveTabuSignature::new( + ) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(MoveTabuSignature::new( MoveTabuScope::new(0, "hard_repair_move"), smallvec![hash_str("vnd_hard_repair_move")], smallvec![hash_str("vnd_hard_repair_move")], - ) + )) } } diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs index c90eb818..9cb6bb96 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt; use std::marker::PhantomData; @@ -369,8 +370,8 @@ where ) } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { - match &self.recipe { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { + Cow::Owned(match &self.recipe { RuntimeListRecipe::Change { access, coordinates, @@ -427,7 +428,7 @@ where } => { multi_swap_tabu_signature(access, coordinates, &self.entity_indices, score_director) } - } + }) } fn candidate_trace_identity(&self) -> Option { diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs b/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs index a0778a9a..904ef0b4 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs @@ -1,5 +1,6 @@ //! One owned move carrier for compiled local-search leaves. +use std::borrow::Cow; use std::fmt::{self, Debug}; use solverforge_core::domain::PlanningSolution; @@ -210,7 +211,7 @@ where } } - fn tabu_signature>(&self, score_director: &D) -> MoveTabuSignature { + fn tabu_signature>(&self, score_director: &D) -> Cow<'_, MoveTabuSignature> { match self { Self::Scalar(mov) => mov.tabu_signature(score_director), Self::List(mov) => mov.tabu_signature(score_director), From 2619725394e81eb167d5464c2e3cc7b7a39774c5 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 21:58:33 +0100 Subject: [PATCH 17/30] test(solver): split parallel evaluation tests and pin the evaluator ceiling An evaluator pool asked for 64 evaluators forks 63 replicas although a window holds at most 32 candidates; the pin expects 31. --- .../src/phase/localsearch/parallel/tests.rs | 332 ------------------ .../phase/localsearch/parallel/tests/mod.rs | 7 + .../phase/localsearch/parallel/tests/pool.rs | 94 +++++ .../localsearch/parallel/tests/sizing.rs | 73 ++++ .../localsearch/parallel/tests/support.rs | 137 ++++++++ .../localsearch/parallel/tests/trajectory.rs | 109 ++++++ 6 files changed, 420 insertions(+), 332 deletions(-) delete mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/pool.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/sizing.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/support.rs create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/trajectory.rs diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs deleted file mode 100644 index d9143b9e..00000000 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests.rs +++ /dev/null @@ -1,332 +0,0 @@ -// Tests for parallel local-search evaluation. - -use solverforge_core::domain::PlanningSolution; -use solverforge_core::score::SoftScore; -use solverforge_core::{ConstraintRef, ImpactType}; -use solverforge_scoring::api::constraint_set::IncrementalConstraint; -use solverforge_scoring::stream::collection_extract::{source, ChangeSource}; -use solverforge_scoring::{Director, IncrementalUniConstraint, ReplicaDescriptor, ScoreDirector}; - -use super::EvaluatorPool; -use crate::heuristic::r#move::ChangeMove; -use crate::heuristic::selector::ChangeMoveSelector; -use crate::phase::localsearch::{ - solve_local_search_with_resources, AcceptedCountForager, BestScoreForager, - HillClimbingAcceptor, LocalSearchForager, SelectorCursorSource, -}; -use crate::scope::SolverScope; -use crate::test_utils::{ - create_nqueens_descriptor, get_queen_row, nqueens_clone_count, reset_nqueens_clone_count, - set_queen_row, NQueensSolution, Queen, -}; - -type QueenMove = ChangeMove; - -fn queen_weight(queen: &Queen) -> SoftScore { - match queen.row { - Some(row) => SoftScore::of((row * 3 + queen.column * 7) % 11), - None => SoftScore::of(20), - } -} - -fn queen_constraints( - _solution: &NQueensSolution, -) -> (impl IncrementalConstraint,) { - (IncrementalUniConstraint::new( - ConstraintRef::new("", "Queen placement"), - ImpactType::Penalty, - source( - (|s: &NQueensSolution| s.queens.as_slice()) as fn(&NQueensSolution) -> &[Queen], - ChangeSource::Descriptor(0), - ), - |_s: &NQueensSolution, _queen: &Queen| true, - queen_weight, - false, - ),) -} - -fn starting_solution() -> NQueensSolution { - NQueensSolution::with_rows(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8]) -} - -/* Builds a director that can fork replicas. A macro, because the director's -type names the opaque constraint set and the borrowed descriptor. -*/ -macro_rules! forkable_director { - ($solution:expr, $descriptor:expr) => {{ - let solution: NQueensSolution = $solution; - let constraints = queen_constraints(&solution); - ScoreDirector::with_descriptor( - solution, - constraints, - ReplicaDescriptor::new($descriptor, queen_constraints), - |s: &NQueensSolution, _| s.queens.len(), - ) - }}; -} - -type QueenSelector = ChangeMoveSelector< - NQueensSolution, - i64, - crate::heuristic::selector::FromSolutionEntitySelector, - crate::heuristic::selector::StaticValueSelector, ->; - -fn selector() -> SelectorCursorSource { - SelectorCursorSource::new(ChangeMoveSelector::simple( - get_queen_row, - set_queen_row, - 0, - 0, - "row", - (0..10).collect(), - )) -} - -struct RunResult { - rows: Vec>, - score: Option, - moves_evaluated: u64, - moves_applied: u64, - moves_speculative: u64, - steps: u64, -} - -fn run( - evaluator_count: usize, - mut forager: Fo, - steps: u64, - move_limit: Option, -) -> RunResult -where - Fo: LocalSearchForager, -{ - let descriptor = create_nqueens_descriptor(); - let mut solver_scope = - SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(7); - solver_scope.start_solving(); - solver_scope.inphase_move_count_limit = move_limit; - let mut source = selector(); - let mut acceptor = HillClimbingAcceptor::new(); - solve_local_search_with_resources( - &mut source, - &mut (), - &mut acceptor, - &mut forager, - Some(steps), - evaluator_count, - &mut solver_scope, - ); - let stats = solver_scope.stats(); - RunResult { - rows: solver_scope - .working_solution() - .queens - .iter() - .map(|queen| queen.row) - .collect(), - score: solver_scope.best_score().copied(), - moves_evaluated: stats.moves_evaluated, - moves_applied: stats.moves_applied, - moves_speculative: stats.moves_speculative, - steps: stats.step_count, - } -} - -#[test] -fn pool_with_one_evaluator_never_forks() { - let director = - ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { - s.queens.len() - }); - let mut solver_scope = SolverScope::new(director); - solver_scope.start_solving(); - let mut pool = EvaluatorPool::::new(1); - - pool.sync(&solver_scope); - - assert!(!pool.is_parallel()); -} - -#[test] -#[should_panic(expected = "constraint-set constructor")] -fn pool_requires_a_forkable_director() { - let director = - ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { - s.queens.len() - }); - let mut solver_scope = SolverScope::new(director); - solver_scope.start_solving(); - let mut pool = EvaluatorPool::::new(2); - - pool.sync(&solver_scope); -} - -#[test] -fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { - let descriptor = create_nqueens_descriptor(); - let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); - solver_scope.start_solving(); - solver_scope.calculate_score(); - let mut pool = EvaluatorPool::::new(4); - - reset_nqueens_clone_count(); - pool.sync(&solver_scope); - assert_eq!(nqueens_clone_count(), 3); - assert_eq!(pool.replicas().count(), 3); - - pool.sync(&solver_scope); - assert_eq!( - nqueens_clone_count(), - 3, - "an unchanged generation must not fork again" - ); - - solver_scope.mutate(|director| { - director.before_variable_changed(0, 0); - director.working_solution_mut().queens[0].row = Some(7); - director.after_variable_changed(0, 0); - }); - let main_score = solver_scope.calculate_score(); - pool.sync(&solver_scope); - - assert_eq!(nqueens_clone_count(), 6); - for replica in pool.replicas() { - assert_eq!(replica.working_solution().queens[0].row, Some(7)); - assert_eq!(replica.working_solution().score(), Some(main_score)); - assert!( - std::ptr::eq(Director::solution_descriptor(replica), &descriptor), - "replicas must share the solve's descriptor, not copy it" - ); - } -} - -#[test] -fn sequential_and_parallel_reach_identical_trajectories() { - let sequential = run(1, AcceptedCountForager::new(4, false), 25, None); - for evaluator_count in [2, 4] { - let parallel = run( - evaluator_count, - AcceptedCountForager::new(4, false), - 25, - None, - ); - assert_eq!(parallel.rows, sequential.rows); - assert_eq!(parallel.score, sequential.score); - assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); - assert_eq!(parallel.moves_applied, sequential.moves_applied); - assert_eq!(parallel.steps, sequential.steps); - } - assert!(sequential.moves_applied > 0); -} - -#[test] -fn speculative_evaluations_are_reported_and_not_counted() { - let sequential = run(1, AcceptedCountForager::new(1, false), 10, None); - let parallel = run(2, AcceptedCountForager::new(1, false), 10, None); - - assert_eq!(parallel.rows, sequential.rows); - assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); - assert_eq!(sequential.moves_speculative, 0); - assert!(parallel.moves_speculative > 0); -} - -#[test] -fn in_phase_move_limit_reserves_the_window() { - let parallel = run(2, BestScoreForager::new(false), 10, Some(5)); - - assert_eq!(parallel.moves_evaluated, 5); - assert_eq!(parallel.moves_speculative, 0); -} - -#[test] -fn replicas_follow_solution_replacement() { - let descriptor = create_nqueens_descriptor(); - let run_with_replacement = |evaluator_count: usize| { - let mut solver_scope = - SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(11); - solver_scope.start_solving(); - let mut source = selector(); - let mut acceptor = HillClimbingAcceptor::new(); - let mut forager = AcceptedCountForager::new(3, false); - solve_local_search_with_resources( - &mut source, - &mut (), - &mut acceptor, - &mut forager, - Some(5), - evaluator_count, - &mut solver_scope, - ); - solver_scope.replace_working_solution_and_reinitialize(NQueensSolution::with_rows(&[ - 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, - ])); - solve_local_search_with_resources( - &mut source, - &mut (), - &mut acceptor, - &mut forager, - Some(5), - evaluator_count, - &mut solver_scope, - ); - solver_scope - .working_solution() - .queens - .iter() - .map(|queen| queen.row) - .collect::>() - }; - - assert_eq!(run_with_replacement(2), run_with_replacement(1)); -} - -#[test] -fn evaluator_count_resolution() { - use solverforge_config::MoveThreadCount; - - use super::resolve_evaluator_count; - - assert_eq!(resolve_evaluator_count(&MoveThreadCount::None), 1); - assert_eq!(resolve_evaluator_count(&MoveThreadCount::Auto), 1); - assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(0)), 1); - assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(1)), 1); - assert_eq!( - resolve_evaluator_count(&MoveThreadCount::Count(2)), - 2.min(rayon::current_num_threads()) - ); -} - -#[test] -fn windows_aim_at_the_previous_steps_consumption() { - let descriptor = create_nqueens_descriptor(); - let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); - solver_scope.start_solving(); - let mut pool = EvaluatorPool::::new(4); - pool.sync(&solver_scope); - - // No history: one candidate per evaluator. - pool.begin_step(); - assert_eq!(pool.window_capacity(None), 4); - pool.record_window(4); - assert_eq!(pool.window_capacity(None), 4); - pool.finish_step(23); - - // Cover the previous consumption, then continue one per evaluator. - pool.begin_step(); - assert_eq!(pool.window_capacity(None), 23); - assert_eq!(pool.window_capacity(Some(5)), 5); - pool.record_window(23); - assert_eq!(pool.window_capacity(None), 4); - pool.finish_step(70); - - // Long steps run in capped windows until the target, then per evaluator. - pool.begin_step(); - assert_eq!(pool.window_capacity(None), 32); - pool.record_window(32); - assert_eq!(pool.window_capacity(None), 32); - pool.record_window(32); - assert_eq!(pool.window_capacity(None), 6); - pool.record_window(6); - assert_eq!(pool.window_capacity(None), 4); -} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs new file mode 100644 index 00000000..92b601ed --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs @@ -0,0 +1,7 @@ +// Tests for parallel local-search evaluation. + +mod support; + +mod pool; +mod sizing; +mod trajectory; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/pool.rs new file mode 100644 index 00000000..6f84f4b0 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/pool.rs @@ -0,0 +1,94 @@ +use solverforge_core::domain::PlanningSolution; +use solverforge_scoring::{Director, ScoreDirector}; + +use super::super::EvaluatorPool; +use super::support::{forkable_director, starting_solution, QueenMove}; +use crate::scope::SolverScope; +use crate::test_utils::{ + create_nqueens_descriptor, nqueens_clone_count, reset_nqueens_clone_count, NQueensSolution, +}; + +#[test] +fn pool_with_one_evaluator_never_forks() { + let director = + ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { + s.queens.len() + }); + let mut solver_scope = SolverScope::new(director); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(1); + + pool.sync(&solver_scope); + + assert!(!pool.is_parallel()); +} + +#[test] +#[should_panic(expected = "constraint-set constructor")] +fn pool_requires_a_forkable_director() { + let director = + ScoreDirector::simple(starting_solution(), create_nqueens_descriptor(), |s, _| { + s.queens.len() + }); + let mut solver_scope = SolverScope::new(director); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(2); + + pool.sync(&solver_scope); +} + +#[test] +fn pool_forks_one_replica_per_extra_evaluator_and_resyncs_after_writes() { + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + solver_scope.calculate_score(); + let mut pool = EvaluatorPool::::new(4); + + reset_nqueens_clone_count(); + pool.sync(&solver_scope); + assert_eq!(nqueens_clone_count(), 3); + assert_eq!(pool.replicas().count(), 3); + + pool.sync(&solver_scope); + assert_eq!( + nqueens_clone_count(), + 3, + "an unchanged generation must not fork again" + ); + + solver_scope.mutate(|director| { + director.before_variable_changed(0, 0); + director.working_solution_mut().queens[0].row = Some(7); + director.after_variable_changed(0, 0); + }); + let main_score = solver_scope.calculate_score(); + pool.sync(&solver_scope); + + assert_eq!(nqueens_clone_count(), 6); + for replica in pool.replicas() { + assert_eq!(replica.working_solution().queens[0].row, Some(7)); + assert_eq!(replica.working_solution().score(), Some(main_score)); + assert!( + std::ptr::eq(Director::solution_descriptor(replica), &descriptor), + "replicas must share the solve's descriptor, not copy it" + ); + } +} + +#[test] +fn pool_caps_evaluators_at_the_window_ceiling() { + use super::super::pool::MAX_PARALLEL_EVALUATION_WINDOW; + + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(64); + + reset_nqueens_clone_count(); + pool.sync(&solver_scope); + + // A full window gives every evaluator a slot; a 33rd would never run. + assert_eq!(pool.replicas().count(), MAX_PARALLEL_EVALUATION_WINDOW - 1); + assert_eq!(nqueens_clone_count(), MAX_PARALLEL_EVALUATION_WINDOW - 1); +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/sizing.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/sizing.rs new file mode 100644 index 00000000..f63863b4 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/sizing.rs @@ -0,0 +1,73 @@ +use super::super::EvaluatorPool; +use super::support::{forkable_director, starting_solution, QueenMove}; +use crate::scope::SolverScope; +use crate::test_utils::{create_nqueens_descriptor, NQueensSolution}; + +#[test] +fn evaluator_count_resolution() { + use solverforge_config::MoveThreadCount; + + use super::super::resolve_evaluator_count; + + assert_eq!(resolve_evaluator_count(&MoveThreadCount::None), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Auto), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(0)), 1); + assert_eq!(resolve_evaluator_count(&MoveThreadCount::Count(1)), 1); + assert_eq!( + resolve_evaluator_count(&MoveThreadCount::Count(2)), + 2.min(rayon::current_num_threads()) + ); +} + +#[test] +fn windows_aim_at_the_previous_steps_consumption() { + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(4); + pool.sync(&solver_scope); + + // No history: one candidate per evaluator. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 4); + pool.record_window(4); + assert_eq!(pool.window_capacity(None), 4); + pool.finish_step(23); + + // Cover the previous consumption, then continue one per evaluator. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 23); + assert_eq!(pool.window_capacity(Some(5)), 5); + pool.record_window(23); + assert_eq!(pool.window_capacity(None), 4); + pool.finish_step(70); + + // Long steps run in capped windows until the target, then per evaluator. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 32); + pool.record_window(32); + assert_eq!(pool.window_capacity(None), 32); + pool.record_window(32); + assert_eq!(pool.window_capacity(None), 6); + pool.record_window(6); + assert_eq!(pool.window_capacity(None), 4); +} + +#[test] +fn windows_above_the_ceiling_stay_within_one_full_window() { + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + let mut pool = EvaluatorPool::::new(64); + pool.sync(&solver_scope); + + // No history: one candidate per evaluator, which is one full window. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 32); + pool.record_window(32); + pool.finish_step(2); + + // Short history still dispatches one candidate per evaluator. + pool.begin_step(); + assert_eq!(pool.window_capacity(None), 32); +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/support.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/support.rs new file mode 100644 index 00000000..5b07ddd7 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/support.rs @@ -0,0 +1,137 @@ +//! Shared fixture for parallel local-search evaluation tests. + +use solverforge_core::score::SoftScore; +use solverforge_core::{ConstraintRef, ImpactType}; +use solverforge_scoring::api::constraint_set::IncrementalConstraint; +use solverforge_scoring::stream::collection_extract::{source, ChangeSource}; +use solverforge_scoring::IncrementalUniConstraint; + +use crate::heuristic::r#move::ChangeMove; +use crate::heuristic::selector::ChangeMoveSelector; +use crate::phase::localsearch::{ + solve_local_search_with_resources, HillClimbingAcceptor, LocalSearchForager, + SelectorCursorSource, +}; +use crate::scope::SolverScope; +use crate::test_utils::{ + create_nqueens_descriptor, get_queen_row, set_queen_row, NQueensSolution, Queen, +}; + +pub(super) type QueenMove = ChangeMove; + +pub(super) fn queen_weight(queen: &Queen) -> SoftScore { + match queen.row { + Some(row) => SoftScore::of((row * 3 + queen.column * 7) % 11), + None => SoftScore::of(20), + } +} + +pub(super) fn queen_constraints( + _solution: &NQueensSolution, +) -> (impl IncrementalConstraint,) { + (IncrementalUniConstraint::new( + ConstraintRef::new("", "Queen placement"), + ImpactType::Penalty, + source( + (|s: &NQueensSolution| s.queens.as_slice()) as fn(&NQueensSolution) -> &[Queen], + ChangeSource::Descriptor(0), + ), + |_s: &NQueensSolution, _queen: &Queen| true, + queen_weight, + false, + ),) +} + +pub(super) fn starting_solution() -> NQueensSolution { + NQueensSolution::with_rows(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8]) +} + +/* Builds a director that can fork replicas. A macro, because the director's +type names the opaque constraint set and the borrowed descriptor. +*/ +macro_rules! forkable_director { + ($solution:expr, $descriptor:expr) => {{ + let solution: crate::test_utils::NQueensSolution = $solution; + let constraints = super::support::queen_constraints(&solution); + solverforge_scoring::ScoreDirector::with_descriptor( + solution, + constraints, + solverforge_scoring::ReplicaDescriptor::new( + $descriptor, + super::support::queen_constraints, + ), + |s: &NQueensSolution, _| s.queens.len(), + ) + }}; +} +pub(super) use forkable_director; + +type QueenSelector = ChangeMoveSelector< + NQueensSolution, + i64, + crate::heuristic::selector::FromSolutionEntitySelector, + crate::heuristic::selector::StaticValueSelector, +>; + +pub(super) fn selector() -> SelectorCursorSource { + SelectorCursorSource::new(ChangeMoveSelector::simple( + get_queen_row, + set_queen_row, + 0, + 0, + "row", + (0..10).collect(), + )) +} + +pub(super) struct RunResult { + pub(super) rows: Vec>, + pub(super) score: Option, + pub(super) moves_generated: u64, + pub(super) moves_evaluated: u64, + pub(super) moves_applied: u64, + pub(super) moves_speculative: u64, + pub(super) steps: u64, +} + +pub(super) fn run( + evaluator_count: usize, + mut forager: Fo, + steps: u64, + move_limit: Option, +) -> RunResult +where + Fo: LocalSearchForager, +{ + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = + SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(7); + solver_scope.start_solving(); + solver_scope.inphase_move_count_limit = move_limit; + let mut source = selector(); + let mut acceptor = HillClimbingAcceptor::new(); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(steps), + evaluator_count, + &mut solver_scope, + ); + let stats = solver_scope.stats(); + RunResult { + rows: solver_scope + .working_solution() + .queens + .iter() + .map(|queen| queen.row) + .collect(), + score: solver_scope.best_score().copied(), + moves_generated: stats.moves_generated, + moves_evaluated: stats.moves_evaluated, + moves_applied: stats.moves_applied, + moves_speculative: stats.moves_speculative, + steps: stats.step_count, + } +} diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/trajectory.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/trajectory.rs new file mode 100644 index 00000000..0525e599 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/trajectory.rs @@ -0,0 +1,109 @@ +use super::support::{forkable_director, run, selector, starting_solution}; +use crate::phase::localsearch::{ + solve_local_search_with_resources, AcceptedCountForager, BestScoreForager, HillClimbingAcceptor, +}; +use crate::scope::SolverScope; +use crate::test_utils::{create_nqueens_descriptor, NQueensSolution}; + +#[test] +fn sequential_and_parallel_reach_identical_trajectories() { + let sequential = run(1, AcceptedCountForager::new(4, false), 25, None); + for evaluator_count in [2, 4] { + let parallel = run( + evaluator_count, + AcceptedCountForager::new(4, false), + 25, + None, + ); + assert_eq!(parallel.rows, sequential.rows); + assert_eq!(parallel.score, sequential.score); + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(parallel.moves_applied, sequential.moves_applied); + assert_eq!(parallel.steps, sequential.steps); + } + assert!(sequential.moves_applied > 0); +} + +#[test] +fn speculative_evaluations_are_reported_and_not_counted() { + let sequential = run(1, AcceptedCountForager::new(1, false), 10, None); + let parallel = run(2, AcceptedCountForager::new(1, false), 10, None); + + assert_eq!(parallel.rows, sequential.rows); + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(sequential.moves_speculative, 0); + assert!(parallel.moves_speculative > 0); +} + +#[test] +fn in_phase_move_limit_reserves_the_window() { + let parallel = run(2, BestScoreForager::new(false), 10, Some(5)); + + assert_eq!(parallel.moves_evaluated, 5); + assert_eq!(parallel.moves_speculative, 0); +} + +#[test] +fn replicas_follow_solution_replacement() { + let descriptor = create_nqueens_descriptor(); + let run_with_replacement = |evaluator_count: usize| { + let mut solver_scope = + SolverScope::new(forkable_director!(starting_solution(), &descriptor)).with_seed(11); + solver_scope.start_solving(); + let mut source = selector(); + let mut acceptor = HillClimbingAcceptor::new(); + let mut forager = AcceptedCountForager::new(3, false); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(5), + evaluator_count, + &mut solver_scope, + ); + solver_scope.replace_working_solution_and_reinitialize(NQueensSolution::with_rows(&[ + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, + ])); + solve_local_search_with_resources( + &mut source, + &mut (), + &mut acceptor, + &mut forager, + Some(5), + evaluator_count, + &mut solver_scope, + ); + solver_scope + .working_solution() + .queens + .iter() + .map(|queen| queen.row) + .collect::>() + }; + + assert_eq!(run_with_replacement(2), run_with_replacement(1)); +} + +#[test] +fn evaluator_counts_above_the_ceiling_keep_the_sequential_trajectory() { + let sequential = run(1, AcceptedCountForager::new(4, false), 25, None); + let parallel = run(64, AcceptedCountForager::new(4, false), 25, None); + + assert_eq!(parallel.rows, sequential.rows); + assert_eq!(parallel.score, sequential.score); + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(parallel.moves_applied, sequential.moves_applied); +} + +#[test] +fn exhausted_move_budget_pulls_like_the_sequential_loop() { + // A full-scan forager never discards speculative work, so generation + // counts compare exactly, including the pull that meets the spent budget. + let sequential = run(1, BestScoreForager::new(false), 10, Some(5)); + let parallel = run(2, BestScoreForager::new(false), 10, Some(5)); + + assert_eq!(parallel.moves_evaluated, sequential.moves_evaluated); + assert_eq!(parallel.moves_generated, sequential.moves_generated); + assert_eq!(parallel.moves_speculative, 0); +} From 5fdfda14b30a068dc40f9ff1757f75cc6e51dda1 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 21:59:13 +0100 Subject: [PATCH 18/30] fix(solver): cap the evaluator pool at one full window 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. --- .../src/phase/localsearch/parallel/config.rs | 3 ++- .../src/phase/localsearch/parallel/pool.rs | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs index 6de83879..c7e13368 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs @@ -2,7 +2,8 @@ use solverforge_config::MoveThreadCount; /* Number of evaluators (main director included) for acceptor-forager local search. `Auto` stays sequential until benchmarks set its policy; an explicit -count is capped by the threads of the pool the solve runs on. +count is capped by the threads of the pool the solve runs on, and the +evaluator pool caps it again at one full window of candidates. */ pub(crate) fn resolve_evaluator_count(count: &MoveThreadCount) -> usize { match count { diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index feaa593a..44cad334 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -25,14 +25,16 @@ pub(super) type TrialOutputs = Vec<(usize, TrialOutcome, Duration)>; pub(crate) type WindowResults = Vec, Duration)>>; /* Evaluators for one local-search phase: the main director plus -`requested - 1` persistent replicas, kept at the committed working solution. +`evaluators - 1` persistent replicas, kept at the committed working solution. +The pool never holds more evaluators than one full window has slots, so every +replica can receive a candidate. */ pub(crate) struct EvaluatorPool where S: PlanningSolution, M: Move, { - requested: usize, + evaluators: usize, replicas: Vec>, main_outputs: TrialOutputs, // Window buffers lent to each step, so steps reuse their capacity. @@ -62,10 +64,11 @@ where D: Director, M: Move, { - // `requested` counts evaluators, the main director included. + // `requested` counts evaluators, the main director included; it is + // capped at the window ceiling. pub(crate) fn new(requested: usize) -> Self { Self { - requested: requested.max(1), + evaluators: requested.clamp(1, MAX_PARALLEL_EVALUATION_WINDOW), replicas: Vec::new(), main_outputs: Vec::new(), window_slots: Vec::new(), @@ -110,11 +113,10 @@ where /// `MAX_PARALLEL_EVALUATION_WINDOW`, capped by the remaining evaluation /// budget. Only the parallel loop sizes windows. pub(crate) fn window_capacity(&self, remaining: Option) -> usize { - let per_evaluator = self.requested.min(MAX_PARALLEL_EVALUATION_WINDOW); let window = self .last_step_consumed .saturating_sub(self.step_dispatched) - .clamp(per_evaluator, MAX_PARALLEL_EVALUATION_WINDOW); + .clamp(self.evaluators, MAX_PARALLEL_EVALUATION_WINDOW); let remaining = remaining.map_or(usize::MAX, |remaining| { usize::try_from(remaining).unwrap_or(usize::MAX) }); @@ -138,11 +140,11 @@ where BestCb: ProgressCallback, { let generation = solver_scope.working_solution_generation(); - if self.requested <= 1 || self.synced_generation == Some(generation) { + if self.evaluators <= 1 || self.synced_generation == Some(generation) { return; } self.replicas.clear(); - for _ in 1..self.requested { + for _ in 1..self.evaluators { let director = solver_scope.score_director().fork_replica().expect( "parallel evaluation requires a director built with a constraint-set constructor", ); From 958286aa86635ea60d26b9f7477d9c01ef39ce77 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:06:04 +0100 Subject: [PATCH 19/30] test(solver): pin allocation-free window trials and runtime K-opt journal 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. --- .../phase/localsearch/parallel/tests/mod.rs | 1 + .../localsearch/parallel/tests/window.rs | 62 +++++++++++++++++++ .../executor/list_leaf/tests/moves.rs | 29 +++++++++ crates/solverforge-solver/src/test_utils.rs | 4 ++ .../src/test_utils/alloc_counter.rs | 39 ++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 crates/solverforge-solver/src/phase/localsearch/parallel/tests/window.rs create mode 100644 crates/solverforge-solver/src/test_utils/alloc_counter.rs diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs index 92b601ed..edc9fe1e 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs @@ -5,3 +5,4 @@ mod support; mod pool; mod sizing; mod trajectory; +mod window; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/tests/window.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/window.rs new file mode 100644 index 00000000..1a078881 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/window.rs @@ -0,0 +1,62 @@ +use crate::heuristic::selector::move_selector::MoveCandidateRef; +use crate::scope::SolverScope; +use crate::test_utils::{create_nqueens_descriptor, thread_allocations, NQueensSolution}; + +use super::super::{evaluate_window, EvaluatorPool}; +use super::support::{forkable_director, starting_solution, QueenMove}; + +fn queen_move(entity: usize, row: i64) -> QueenMove { + QueenMove::new( + entity, + Some(row), + crate::test_utils::get_queen_row, + crate::test_utils::set_queen_row, + 0, + "row", + 0, + ) +} + +#[test] +fn window_dispatch_makes_no_heap_allocations() { + // One pool thread runs the coordinator and every replica task, so the + // thread-local counter sees every allocation the window makes. + let pool_thread = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .expect("test pool must build"); + pool_thread.install(|| { + let descriptor = create_nqueens_descriptor(); + let mut solver_scope = + SolverScope::new(forkable_director!(starting_solution(), &descriptor)); + solver_scope.start_solving(); + solver_scope.calculate_score(); + let mut evaluators = EvaluatorPool::::new(4); + evaluators.sync(&solver_scope); + + let moves: Vec = (0..8).map(|entity| queen_move(entity, 7)).collect(); + let candidates: Vec<_> = moves.iter().map(MoveCandidateRef::Borrowed).collect(); + let mut journal_pool = Vec::new(); + let mut results = Vec::new(); + + // The first window sizes the reused buffers. + evaluate_window( + &candidates, + solver_scope.score_director_mut(), + &mut journal_pool, + &mut evaluators, + &mut results, + ); + let before = thread_allocations(); + evaluate_window( + &candidates, + solver_scope.score_director_mut(), + &mut journal_pool, + &mut evaluators, + &mut results, + ); + + assert_eq!(thread_allocations() - before, 0); + assert!(results.iter().all(Option::is_some)); + }); +} diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/tests/moves.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/tests/moves.rs index f83ac424..9dd8ed38 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/tests/moves.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/tests/moves.rs @@ -226,3 +226,32 @@ fn candidate_enumeration_never_clones_full_slot_meters() { ); } } + +#[test] +fn compiled_runtime_carrier_recycles_k_opt_journals() { + use crate::runtime::compiler::executor::local_search::RuntimeNeighborhoodMove; + + let selector = selector( + ListLeafKind::KOpt, + static_slot_for(ListLeafKind::KOpt), + Some(71), + ); + let mut score_director = director(initial_plan()); + let carrier = RuntimeNeighborhoodMove::List(first_doable(&selector, &mut score_director)); + let mut journal_pool = Vec::new(); + + let undo = carrier.do_move_with_scratch(&mut score_director, &mut journal_pool); + carrier.undo_move_with_scratch(&mut score_director, undo, &mut journal_pool); + assert_eq!( + journal_pool.len(), + 1, + "the carrier must return the retired K-opt journal to the pool" + ); + + let undo = carrier.do_move_with_scratch(&mut score_director, &mut journal_pool); + assert!( + journal_pool.is_empty(), + "the next K-opt trial must reuse the retired journal" + ); + carrier.undo_move_with_scratch(&mut score_director, undo, &mut journal_pool); +} diff --git a/crates/solverforge-solver/src/test_utils.rs b/crates/solverforge-solver/src/test_utils.rs index 8096a75d..86eaa8fc 100644 --- a/crates/solverforge-solver/src/test_utils.rs +++ b/crates/solverforge-solver/src/test_utils.rs @@ -3,6 +3,10 @@ Provides common test fixtures used across the crate's test modules. */ +mod alloc_counter; + +pub use alloc_counter::thread_allocations; + use crate::scope::SolverScope; use solverforge_core::domain::{ EntityCollectionExtractor, EntityDescriptor, PlanningSolution, SolutionDescriptor, diff --git a/crates/solverforge-solver/src/test_utils/alloc_counter.rs b/crates/solverforge-solver/src/test_utils/alloc_counter.rs new file mode 100644 index 00000000..bbfbc8e7 --- /dev/null +++ b/crates/solverforge-solver/src/test_utils/alloc_counter.rs @@ -0,0 +1,39 @@ +/* Per-thread heap allocation counter for the crate's test binary. + +Each thread counts its own allocations, so a test that runs its work on one +thread reads an exact count no matter what concurrently running tests do on +theirs. +*/ + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +struct CountingAllocator; + +thread_local! { + static THREAD_ALLOCATIONS: Cell = const { Cell::new(0) }; +} + +#[global_allocator] +static GLOBAL: CountingAllocator = CountingAllocator; + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + THREAD_ALLOCATIONS.with(|count| count.set(count.get() + 1)); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) } + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + THREAD_ALLOCATIONS.with(|count| count.set(count.get() + 1)); + unsafe { System.realloc(pointer, layout, new_size) } + } +} + +/// Heap allocations made so far on the calling thread. +pub fn thread_allocations() -> usize { + THREAD_ALLOCATIONS.with(Cell::get) +} From e8ee5be2ec9745d154f6d266c7ecd06f793bc843 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:06:29 +0100 Subject: [PATCH 20/30] perf(solver): dispatch evaluation windows without heap allocation 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. --- AGENTS.md | 8 +- crates/solverforge-solver/WIREFRAME.md | 4 +- .../selector/move_selector/borrowed.rs | 108 ++++++++---------- .../src/manager/phase_factory/k_opt.rs | 8 +- .../src/phase/localsearch/evaluation.rs | 14 +-- .../src/phase/localsearch/parallel/mod.rs | 2 +- .../src/phase/localsearch/parallel/pool.rs | 4 +- .../src/phase/localsearch/parallel/window.rs | 50 ++++---- .../src/phase/localsearch/phase/candidates.rs | 17 +-- .../src/phase/localsearch/phase/sequential.rs | 4 +- .../src/phase/localsearch/vnd/phase.rs | 4 +- 11 files changed, 112 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ee10c4d..91f29a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,9 +281,11 @@ Use `rayon` for CPU-bound work and `tokio` for async coordination. - Do not add `std::thread::spawn`. - Do not add `std::thread::sleep`. - Prefer ownership transfer over callback APIs that only expose `&Self`. -- Parallel local-search evaluation runs one `rayon::scope` per evaluation - window on the solve thread's current pool; do not add dedicated pools or - blocking waits inside evaluation workers. +- Parallel local-search evaluation runs one fork-join (`rayon::join` plus a + parallel iterator over the replicas) per evaluation window on the solve + thread's current pool. Fork-join jobs live on the stack, so window dispatch + allocates nothing; do not reintroduce per-window spawned tasks, dedicated + pools, or blocking waits inside evaluation workers. ### Configured Completion diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index eac76199..f3eaada3 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -1260,8 +1260,8 @@ a fixed-size candidate batch. can evaluate candidates on several cores within one solve. Each step produces a window of candidates serially on the main director, in canonical order; evaluates the window on the main director plus `n - 1` persistent evaluator -replicas (one `rayon::scope` per window, slots handed out through one atomic -counter); and consumes the results in candidate order through the unchanged +replicas (one allocation-free fork-join per window, slots handed out through +one atomic counter); and consumes the results in candidate order through the unchanged acceptor and forager. The selected move is applied to every replica by borrow before the owned commit, and each replica must reach the committed step score. Replicas are forked from the working solution through diff --git a/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs b/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs index 1ee2b2ae..fd4a8e16 100644 --- a/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs +++ b/crates/solverforge-solver/src/heuristic/selector/move_selector/borrowed.rs @@ -155,64 +155,6 @@ where } } - fn do_move_with_scratch>( - &self, - score_director: &mut D, - journal_pool: &mut Vec, - ) -> Self::Undo { - match self { - Self::Borrowed(mov) => { - // Only Borrowed-shaped entries carry an inner undo journal - // this arm may reuse. - let popped = journal_pool - .iter() - .rposition(|entry| matches!(entry, MoveCandidateUndo::Borrowed(_))) - .map(|index| match journal_pool.swap_remove(index) { - MoveCandidateUndo::Borrowed(inner) => inner, - _ => unreachable!("rposition matched the Borrowed shape"), - }); - let mut pool = popped.map(|inner| vec![inner]).unwrap_or_default(); - let undo = mov.do_move_with_scratch(score_director, &mut pool); - for inner in pool { - journal_pool.push(MoveCandidateUndo::Borrowed(inner)); - } - MoveCandidateUndo::Borrowed(undo) - } - Self::Sequential(mov) => { - // Sequential legs own their do/undo pairs directly; pooled - // journals across differently shaped legs need per-leg - // keying the composite does not provide. - let first = mov.first().do_move(score_director); - let second = mov.second().do_move(score_director); - MoveCandidateUndo::Sequential { first, second } - } - } - } - - fn undo_move_with_scratch>( - &self, - score_director: &mut D, - undo: Self::Undo, - journal_pool: &mut Vec, - ) { - match (self, undo) { - (Self::Borrowed(mov), MoveCandidateUndo::Borrowed(undo)) => { - // The restore already owns the inner undo; run the scratch - // undo on a one-slot pool of exactly that shape. - let mut pool = vec![undo]; - mov.undo_move_with_scratch(score_director, pool.swap_remove(0), &mut pool); - for inner in pool { - journal_pool.push(MoveCandidateUndo::Borrowed(inner)); - } - } - (Self::Sequential(mov), MoveCandidateUndo::Sequential { first, second }) => { - mov.second().undo_move(score_director, second); - mov.first().undo_move(score_director, first); - } - _ => panic!("move candidate undo shape must match candidate shape"), - } - } - fn undo_move>(&self, score_director: &mut D, undo: Self::Undo) { match (self, undo) { (Self::Borrowed(mov), MoveCandidateUndo::Borrowed(undo)) => { @@ -296,6 +238,56 @@ where } } +impl MoveCandidateRef<'_, S, M> +where + S: PlanningSolution, + M: Move, +{ + /// Applies the candidate, letting a borrowed move reuse a retired undo + /// journal of its own type from `journal_pool`. + /// + /// The pool holds the moves' own undo values, so a borrowed candidate + /// lends it to the move directly and the trial allocates nothing. + /// Sequential legs own their do/undo pairs; pooled journals across + /// differently shaped legs need per-leg keying the composite does not + /// provide. + pub(crate) fn do_move_recycling>( + &self, + score_director: &mut D, + journal_pool: &mut Vec, + ) -> MoveCandidateUndo { + match self { + Self::Borrowed(mov) => { + MoveCandidateUndo::Borrowed(mov.do_move_with_scratch(score_director, journal_pool)) + } + Self::Sequential(mov) => { + let first = mov.first().do_move(score_director); + let second = mov.second().do_move(score_director); + MoveCandidateUndo::Sequential { first, second } + } + } + } + + /// Reverts `do_move_recycling`, returning retired journals to the pool. + pub(crate) fn undo_move_recycling>( + &self, + score_director: &mut D, + undo: MoveCandidateUndo, + journal_pool: &mut Vec, + ) { + match (self, undo) { + (Self::Borrowed(mov), MoveCandidateUndo::Borrowed(undo)) => { + mov.undo_move_with_scratch(score_director, undo, journal_pool); + } + (Self::Sequential(mov), MoveCandidateUndo::Sequential { first, second }) => { + mov.second().undo_move(score_director, second); + mov.first().undo_move(score_director, first); + } + _ => panic!("move candidate undo shape must match candidate shape"), + } + } +} + /// Incremental, cursor-owned candidate storage for a single neighborhood. /// /// `next_candidate()` constructs or discovers at most the next candidate and diff --git a/crates/solverforge-solver/src/manager/phase_factory/k_opt.rs b/crates/solverforge-solver/src/manager/phase_factory/k_opt.rs index 7a7abd01..af77ec47 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/k_opt.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/k_opt.rs @@ -193,12 +193,12 @@ where fn solve(&mut self, solver_scope: &mut SolverScope) { use crate::heuristic::r#move::{ListUndoJournal, Move}; use crate::heuristic::selector::entity::FromSolutionEntitySelector; - use crate::heuristic::selector::move_selector::{MoveCandidateUndo, MoveSelector}; + use crate::heuristic::selector::move_selector::MoveSelector; let mut phase_scope = PhaseScope::with_phase_type(solver_scope, 0, "K-Opt"); // Retired K-opt journals recycle across the phase's trials; arbitrary // route lengths do not grow the heap per trial. - let mut journal_pool: Vec>> = Vec::new(); + let mut journal_pool: Vec> = Vec::new(); // Calculate initial score let mut last_step_score = phase_scope.calculate_score(); @@ -285,9 +285,9 @@ where .expect("k-opt candidate id must remain live during evaluation"); let score_state = step_scope.score_director().snapshot_score_state(); let undo = - mv.do_move_with_scratch(step_scope.score_director_mut(), &mut journal_pool); + mv.do_move_recycling(step_scope.score_director_mut(), &mut journal_pool); let move_score = step_scope.calculate_score(); - mv.undo_move_with_scratch( + mv.undo_move_recycling( step_scope.score_director_mut(), undo, &mut journal_pool, diff --git a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs index 3d438209..03f463da 100644 --- a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs +++ b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs @@ -5,7 +5,7 @@ use solverforge_core::score::Score; use solverforge_scoring::Director; use crate::heuristic::r#move::Move; -use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; +use crate::heuristic::selector::move_selector::MoveCandidateRef; use crate::phase::hard_delta::{hard_score_delta, HardScoreDelta}; use crate::pinning::move_changes_pinned; use crate::scope::{ProgressCallback, StepScope}; @@ -32,7 +32,7 @@ pub(crate) fn evaluate_candidate( reference_score: S::Score, selector_index: Option, evaluation_started: Instant, - journal_pool: &mut Vec>, + journal_pool: &mut Vec, ) -> CandidateEvaluation where S: PlanningSolution, @@ -62,9 +62,9 @@ where } let score_state = step_scope.score_director().snapshot_score_state(); - let undo = mov.do_move_with_scratch(step_scope.score_director_mut(), journal_pool); + let undo = mov.do_move_recycling(step_scope.score_director_mut(), journal_pool); let move_score = step_scope.score_director_mut().calculate_score(); - mov.undo_move_with_scratch(step_scope.score_director_mut(), undo, journal_pool); + mov.undo_move_recycling(step_scope.score_director_mut(), undo, journal_pool); step_scope .score_director_mut() .restore_score_state(score_state); @@ -134,7 +134,7 @@ undo journal, so it can run on an evaluator replica. pub(crate) fn run_trial( mov: &MoveCandidateRef<'_, S, M>, director: &mut D, - journal_pool: &mut Vec>, + journal_pool: &mut Vec, ) -> TrialOutcome where S: PlanningSolution, @@ -146,9 +146,9 @@ where } let score_state = director.snapshot_score_state(); - let undo = mov.do_move_with_scratch(director, journal_pool); + let undo = mov.do_move_recycling(director, journal_pool); let move_score = director.calculate_score(); - mov.undo_move_with_scratch(director, undo, journal_pool); + mov.undo_move_recycling(director, undo, journal_pool); director.restore_score_state(score_state); TrialOutcome::Scored(move_score) } diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs index 524e9ac3..ebf1e6ed 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs @@ -15,5 +15,5 @@ mod tests; pub(crate) use budget::remaining_evaluation_budget; pub(crate) use config::resolve_evaluator_count; -pub(crate) use pool::EvaluatorPool; +pub(crate) use pool::{EvaluatorPool, MAX_PARALLEL_EVALUATION_WINDOW}; pub(crate) use window::evaluate_window; diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs index 44cad334..dcee4aaa 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -6,7 +6,7 @@ use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; use crate::heuristic::r#move::Move; -use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; +use crate::heuristic::selector::move_selector::MoveCandidateRef; use crate::phase::localsearch::evaluation::TrialOutcome; use crate::phase::localsearch::phase::WindowSlot; use crate::scope::{ProgressCallback, SolverScope}; @@ -54,7 +54,7 @@ where M: Move, { pub(super) director: D, - pub(super) journal_pool: Vec>, + pub(super) journal_pool: Vec, pub(super) outputs: TrialOutputs, } diff --git a/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs index 8cc1aba6..a753b777 100644 --- a/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs @@ -1,24 +1,27 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; +use rayon::prelude::*; use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; use crate::heuristic::r#move::Move; -use crate::heuristic::selector::move_selector::{MoveCandidateRef, MoveCandidateUndo}; +use crate::heuristic::selector::move_selector::MoveCandidateRef; use crate::phase::localsearch::evaluation::{run_trial, TrialOutcome}; use super::pool::{EvaluatorPool, TrialOutputs}; -/* Runs the trial for every candidate in the window. The main director takes -its share inline on the coordinator thread; each replica takes its share in a -spawned task. Slots are handed out through one atomic counter, and results land -at their slot index, so which evaluator ran a slot never affects the outcome. +/* Runs the trial for every candidate in the window. One fork-join splits the +work: the main director drains slots on the coordinator thread while the +replicas drain theirs through a parallel iterator. Fork-join jobs live on the +stack, so dispatch allocates nothing. Slots are handed out through one atomic +counter, and results land at their slot index, so which evaluator ran a slot +never affects the outcome. */ pub(crate) fn evaluate_window( candidates: &[MoveCandidateRef<'_, S, M>], main_director: &mut D, - main_journal_pool: &mut Vec>, + main_journal_pool: &mut Vec, evaluators: &mut EvaluatorPool, results: &mut Vec, Duration)>>, ) where @@ -29,28 +32,29 @@ pub(crate) fn evaluate_window( let next = AtomicUsize::new(0); let (main_outputs, replicas) = evaluators.parts_mut(); main_outputs.clear(); - rayon::scope(|scope| { - for replica in replicas.iter_mut() { - let next = &next; - replica.outputs.clear(); - scope.spawn(move |_| { + rayon::join( + || { + drain_window( + candidates, + &next, + main_director, + main_journal_pool, + main_outputs, + ) + }, + || { + replicas.par_iter_mut().for_each(|replica| { + replica.outputs.clear(); drain_window( candidates, - next, + &next, &mut replica.director, &mut replica.journal_pool, &mut replica.outputs, ); - }); - } - drain_window( - candidates, - &next, - main_director, - main_journal_pool, - main_outputs, - ); - }); + }) + }, + ); results.clear(); results.resize_with(candidates.len(), || None); for (index, outcome, elapsed) in main_outputs.drain(..) { @@ -67,7 +71,7 @@ fn drain_window( candidates: &[MoveCandidateRef<'_, S, M>], next: &AtomicUsize, director: &mut D, - journal_pool: &mut Vec>, + journal_pool: &mut Vec, outputs: &mut TrialOutputs, ) where S: PlanningSolution, diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs index 07f85846..8ece37e0 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -1,17 +1,16 @@ //! Candidate state for one local-search step and the parallel window loop. +use smallvec::SmallVec; use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; use crate::heuristic::r#move::Move; -use crate::heuristic::selector::move_selector::{ - CandidateId, MoveCandidateUndo, ResourceMoveCursor, -}; +use crate::heuristic::selector::move_selector::{CandidateId, ResourceMoveCursor}; use crate::phase::control::{ should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, }; use crate::phase::localsearch::parallel::{ - evaluate_window, remaining_evaluation_budget, EvaluatorPool, + evaluate_window, remaining_evaluation_budget, EvaluatorPool, MAX_PARALLEL_EVALUATION_WINDOW, }; use crate::phase::localsearch::{Acceptor, LocalSearchForager}; use crate::scope::{ProgressCallback, StepScope}; @@ -79,7 +78,7 @@ where // Each pass produces a window of candidates serially on the main // director, evaluates it across the evaluator pool, and consumes the // results in candidate order through the sequential loop's checks. - let mut journal_pool: Vec> = Vec::new(); + let mut journal_pool: Vec = Vec::new(); let mut control_polls = 0usize; let mut control_latched = false; let (mut window, mut results) = evaluators.take_window_buffers(); @@ -142,8 +141,10 @@ where break; } - // Evaluation. - let candidates: Vec<_> = window + // Evaluation. A window never exceeds the pool's ceiling, so the + // borrowed candidates stay in inline storage. + debug_assert!(window.len() <= MAX_PARALLEL_EVALUATION_WINDOW); + let candidates: SmallVec<[_; MAX_PARALLEL_EVALUATION_WINDOW]> = window .iter() .map(|slot| { cursor @@ -158,6 +159,8 @@ where evaluators, &mut results, ); + // The borrowed candidates end here; consumption releases them. + drop(candidates); // Consumption in candidate order. for index in 0..window.len() { diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs index 4cbebc0a..a7d46669 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs @@ -7,7 +7,7 @@ use solverforge_scoring::Director; use tracing::trace; use crate::heuristic::r#move::Move; -use crate::heuristic::selector::move_selector::{MoveCandidateUndo, ResourceMoveCursor}; +use crate::heuristic::selector::move_selector::ResourceMoveCursor; use crate::phase::control::{ should_interrupt_before_candidate, should_interrupt_before_evaluation, CONTROL_POLL_INTERVAL, }; @@ -49,7 +49,7 @@ where // its every-64-evaluations schedule below. // Retired undo journals are recycled across the step's trials so // arbitrary-long journals do not grow the heap per trial. - let mut journal_pool: Vec> = Vec::new(); + let mut journal_pool: Vec = Vec::new(); let mut control_polls = 0usize; let mut control_latched = false; while !forager.is_quit_early() { diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs index 38d98c48..1074dd86 100644 --- a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs +++ b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs @@ -8,7 +8,7 @@ use tracing::info; use crate::heuristic::r#move::Move; use crate::heuristic::selector::move_selector::{ - CandidateId, MoveCandidateUndo, MoveStreamContext, ResourceMoveCursor, + CandidateId, MoveStreamContext, ResourceMoveCursor, }; use crate::phase::control::{ settle_search_interrupt, should_interrupt_after_step, should_interrupt_before_candidate, @@ -195,7 +195,7 @@ where let mut best: Option<(CandidateId, S::Score, Option)> = None; // Retired undo journals are recycled across this step's trials so // arbitrary-long journals do not grow the heap per trial. - let mut journal_pool: Vec> = Vec::new(); + let mut journal_pool: Vec = Vec::new(); loop { if should_interrupt_before_candidate(step_scope) { From dfdfe64fc951fea09d24f7f29fb7823b24ba6a7c Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:08:00 +0100 Subject: [PATCH 21/30] fix(solver): recycle K-opt undo journals through the compiled runtime 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. --- .../compiler/executor/list_leaf/mod.rs | 1 + .../compiler/executor/list_leaf/move.rs | 122 +++++++++++------- .../compiler/executor/local_search/move.rs | 46 ++++++- .../src/runtime/compiler/executor/mod.rs | 4 +- 4 files changed, 126 insertions(+), 47 deletions(-) diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/mod.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/mod.rs index 8fe70948..dd0dc4f6 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/mod.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/mod.rs @@ -24,6 +24,7 @@ pub(crate) use cursor::{ RuntimeListNeighborhoodStreamState, }; pub(crate) use r#move::RuntimeListMove; +pub(crate) use recipe::RuntimeListMoveUndo; pub(crate) use spec::{ RuntimeListNeighborhoodPlan, RuntimeListNeighborhoodPlanError, RuntimeListRecipe, }; diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs index 9cb6bb96..4f0aa0ce 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/move.rs @@ -7,6 +7,7 @@ use solverforge_core::domain::PlanningSolution; use solverforge_core::score::Score; use solverforge_scoring::Director; +use crate::builder::context::RuntimeListElement; use crate::heuristic::r#move::list_kernel::{ change_candidate_trace_identity, change_do_move, change_is_doable, change_tabu_signature, change_undo_move, k_opt_do_move, k_opt_do_move_into, k_opt_is_doable, k_opt_tabu_signature, @@ -55,6 +56,71 @@ impl RuntimeListMove { } } +impl RuntimeListMove +where + S: PlanningSolution + Clone + Send + Sync + 'static, + S::Score: Score, + V: Clone + PartialEq + Send + Sync + fmt::Debug + 'static, + DM: Clone + Send + Sync + fmt::Debug + CrossEntityDistanceMeter, + IDM: Clone + Send + Sync + fmt::Debug + CrossEntityDistanceMeter, +{ + /// Whether this move records an arbitrary-length undo journal whose + /// capacity a later trial can reuse. + pub(crate) fn reuses_journal(&self) -> bool { + matches!(self.recipe, RuntimeListRecipe::KOpt { .. }) + } + + /// Applies the move, recording into `retired` when it is a journal shell. + /// Wrapping carriers take the shell from their own pool, so no layer + /// builds a scratch pool of this move's undo type. + pub(crate) fn do_move_reusing>( + &self, + score_director: &mut D, + retired: Option>>, + ) -> RuntimeListMoveUndo { + let RuntimeListRecipe::KOpt { + access, + entity, + cuts, + reconnection, + .. + } = &self.recipe + else { + return Move::do_move(self, score_director); + }; + let mut journal = retired.unwrap_or_default(); + k_opt_do_move_into( + access, + cuts, + reconnection, + *entity, + score_director, + &mut journal, + ); + RuntimeListMoveUndo::KOpt(journal) + } + + /// Reverts the move and returns an empty journal shell for the next + /// trial when the undo consumed a journal. + pub(crate) fn undo_move_retiring>( + &self, + score_director: &mut D, + undo: RuntimeListMoveUndo, + ) -> Option>> { + let RuntimeListMoveUndo::KOpt(values) = undo else { + Move::undo_move(self, score_director, undo); + return None; + }; + let RuntimeListRecipe::KOpt { access, entity, .. } = &self.recipe else { + panic!("runtime list move undo shape must match recipe"); + }; + // The undo consumes the restored route journal; a fresh shell keeps + // the inline capacity for the next K-opt trial. + k_opt_undo_move(access, *entity, values, score_director); + Some(ListUndoJournal::new()) + } +} + impl fmt::Debug for RuntimeListMove { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -127,37 +193,17 @@ where score_director: &mut D, journal_pool: &mut Vec, ) -> Self::Undo { - match &self.recipe { - RuntimeListRecipe::KOpt { - access, - entity, - cuts, - reconnection, - .. - } => { - // Reuse a retired K-opt journal from the pool; its inlined - // capacity survives the do/undo cycle. - let popped = journal_pool - .iter() - .rposition(|undo| matches!(undo, RuntimeListMoveUndo::KOpt(_))) - .map(|index| match journal_pool.swap_remove(index) { - RuntimeListMoveUndo::KOpt(journal) => journal, - _ => unreachable!("rposition matched the KOpt shape"), - }); - let mut journal = popped.unwrap_or_default(); - k_opt_do_move_into( - access, - cuts, - reconnection, - *entity, - score_director, - &mut journal, - ); - RuntimeListMoveUndo::KOpt(journal) - } - // Non-journal recipes keep the plain single-allocation path. - _ => Move::do_move(self, score_director), + if !self.reuses_journal() { + return Move::do_move(self, score_director); } + let retired = journal_pool + .iter() + .rposition(|undo| matches!(undo, RuntimeListMoveUndo::KOpt(_))) + .map(|index| match journal_pool.swap_remove(index) { + RuntimeListMoveUndo::KOpt(journal) => journal, + _ => unreachable!("rposition matched the KOpt shape"), + }); + self.do_move_reusing(score_director, retired) } fn undo_move_with_scratch>( @@ -166,21 +212,9 @@ where undo: Self::Undo, journal_pool: &mut Vec, ) { - if matches!(undo, RuntimeListMoveUndo::KOpt(_)) { - let RuntimeListRecipe::KOpt { access, entity, .. } = &self.recipe else { - panic!("runtime list move undo shape must match recipe"); - }; - let RuntimeListMoveUndo::KOpt(values) = undo else { - unreachable!("shape tested above"); - }; - // The restored route journal is consumed by the undo; a fresh - // journal shell goes back to the pool so the next K-opt trial - // reuses inline capacity. - k_opt_undo_move(access, *entity, values, score_director); - journal_pool.push(RuntimeListMoveUndo::KOpt(ListUndoJournal::new())); - return; + if let Some(shell) = self.undo_move_retiring(score_director, undo) { + journal_pool.push(RuntimeListMoveUndo::KOpt(shell)); } - self.undo_move(score_director, undo); } fn do_move>(&self, score_director: &mut D) -> Self::Undo { diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs b/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs index 904ef0b4..b57f6dd9 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs @@ -16,7 +16,7 @@ use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter; use crate::heuristic::selector::scalar_neighborhood::RuntimeScalarMove; use crate::stats::CandidateTraceIdentity; -use super::super::RuntimeListMove; +use super::super::{RuntimeListMove, RuntimeListMoveUndo}; /// Fully concrete carrier for scalar, list, assignment-group, provider, and /// selected Cartesian moves. It is the only carrier produced by compiled @@ -130,6 +130,50 @@ where } } + fn do_move_with_scratch>( + &self, + score_director: &mut D, + journal_pool: &mut Vec, + ) -> Self::Undo { + let Self::List(mov) = self else { + return self.do_move(score_director); + }; + if !mov.reuses_journal() { + return self.do_move(score_director); + } + let retired = journal_pool + .iter() + .rposition(|undo| { + matches!( + undo, + RuntimeNeighborhoodMoveUndo::List(RuntimeListMoveUndo::KOpt(_)) + ) + }) + .map(|index| match journal_pool.swap_remove(index) { + RuntimeNeighborhoodMoveUndo::List(RuntimeListMoveUndo::KOpt(journal)) => journal, + _ => unreachable!("rposition matched the list K-opt shape"), + }); + RuntimeNeighborhoodMoveUndo::List(mov.do_move_reusing(score_director, retired)) + } + + fn undo_move_with_scratch>( + &self, + score_director: &mut D, + undo: Self::Undo, + journal_pool: &mut Vec, + ) { + match (self, undo) { + (Self::List(mov), RuntimeNeighborhoodMoveUndo::List(undo)) => { + if let Some(shell) = mov.undo_move_retiring(score_director, undo) { + journal_pool.push(RuntimeNeighborhoodMoveUndo::List( + RuntimeListMoveUndo::KOpt(shell), + )); + } + } + (other, undo) => other.undo_move(score_director, undo), + } + } + fn undo_move>(&self, score_director: &mut D, undo: Self::Undo) { match (self, undo) { (Self::Scalar(mov), RuntimeNeighborhoodMoveUndo::Scalar(undo)) => { diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs b/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs index f333e537..641258a9 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs @@ -23,8 +23,8 @@ pub(crate) use execution_record::{ ResolvedConstructionExecutionStep, }; pub(crate) use list_leaf::{ - CompiledListNeighborhoodLeafAdapter, RuntimeListMove, RuntimeListNeighborhoodCursor, - RuntimeListNeighborhoodLeaf, RuntimeListNeighborhoodStreamState, + CompiledListNeighborhoodLeafAdapter, RuntimeListMove, RuntimeListMoveUndo, + RuntimeListNeighborhoodCursor, RuntimeListNeighborhoodLeaf, RuntimeListNeighborhoodStreamState, }; pub(crate) use prepared::{ CompiledRuntimeExecutor, PreparedConstruction, PreparedDefaultRuntime, PreparedListSlot, From e446566f24a3b6901dd2bf4dd5040485d604c8a8 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:09:32 +0100 Subject: [PATCH 22/30] test(bridge): pin DynamicScore equality, thread-independent zero, and 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. --- crates/solverforge-bridge/src/score_tests.rs | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/solverforge-bridge/src/score_tests.rs b/crates/solverforge-bridge/src/score_tests.rs index fd2dcee1..728e25e4 100644 --- a/crates/solverforge-bridge/src/score_tests.rs +++ b/crates/solverforge-bridge/src/score_tests.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; + use solverforge_core::score::{ParseableScore, Score}; use crate::{scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily}; @@ -55,3 +57,32 @@ fn dynamic_score_parses_standard_score_strings() { ); assert_eq!(DynamicScore::parse("3").unwrap(), DynamicScore::soft(3)); } + +#[test] +fn dynamic_score_equality_agrees_with_ordering() { + let declared = DynamicScore::hard_soft(0, 0); + + assert_eq!(declared.cmp(&DynamicScore::ZERO), Ordering::Equal); + assert_eq!(declared, DynamicScore::ZERO); +} + +#[test] +fn dynamic_score_zero_does_not_depend_on_the_calling_thread() { + // Evaluator replicas score on worker threads that never entered the + // solve's family scope; their zero must equal the solve thread's. + let solve_thread_zero = scoped_dynamic_score_family(DynamicScoreFamily::HardSoft, || { + ::zero() + }); + + assert_eq!(solve_thread_zero, ::zero()); +} + +#[test] +fn dynamic_score_parse_inverts_display_for_decimal_models() { + scoped_dynamic_score_family(DynamicScoreFamily::HardSoftDecimal, || { + let limit = DynamicScore::parse("0hard/-5soft").unwrap(); + + assert_eq!((limit.hard, limit.soft), (0, -500_000)); + assert_eq!(limit.to_string(), "0hard/-5soft"); + }); +} From 8cf6a0c1fb52ff947e501677ca5ace76b52dc281 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:12:05 +0100 Subject: [PATCH 23/30] fix(bridge)!: make DynamicScore a plain three-level value 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`). --- crates/solverforge-bridge/WIREFRAME.md | 40 ++-- crates/solverforge-bridge/src/lib.rs | 4 +- crates/solverforge-bridge/src/score.rs | 217 +++++-------------- crates/solverforge-bridge/src/score_tests.rs | 60 +++-- 4 files changed, 129 insertions(+), 192 deletions(-) diff --git a/crates/solverforge-bridge/WIREFRAME.md b/crates/solverforge-bridge/WIREFRAME.md index 30a78866..8fc7bf13 100644 --- a/crates/solverforge-bridge/WIREFRAME.md +++ b/crates/solverforge-bridge/WIREFRAME.md @@ -28,7 +28,7 @@ src/ ├── ids.rs - Re-exports logical descriptor ID types from `solverforge-core` ├── lib.rs - Crate root; module declarations and public re-exports ├── runner.rs - Binding-oriented dynamic runner helper -├── score.rs - `DynamicScore`, `DynamicScoreFamily`, and scoped family guard +├── score.rs - `DynamicScore`, `DynamicScoreFamily`, and the scoped presentation family ├── score_tests.rs - Dynamic score parse/display/arithmetic tests └── slots.rs - Re-exports dynamic scalar/list slot adapters from `solverforge-core` ``` @@ -43,7 +43,9 @@ pub use backend::{ }; pub use ids::{EntityClassId, ProblemFactClassId, VariableId}; pub use runner::try_run_dynamic_solver_with_config_parts; -pub use score::{scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily}; +pub use score::{ + active_dynamic_score_family, scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily, +}; pub use slots::{DynamicListVariableSlot, DynamicScalarVariableSlot}; ``` @@ -145,7 +147,6 @@ pub struct DynamicScore { pub hard: i64, pub medium: i64, pub soft: i64, - pub family: DynamicScoreFamily, } ``` @@ -153,12 +154,8 @@ Constructors: - `ZERO` - `of(hard, medium, soft)` -- `with_family(hard, medium, soft, family)` - `soft(soft)` - `hard_soft(hard, soft)` -- `hard_soft_decimal(hard_scaled, soft_scaled)` -- `hard_medium_soft(hard, medium, soft)` -- `zero_for_family(family)` Methods: @@ -173,15 +170,28 @@ Implements: - `Add`, `Sub`, `Neg` - `Ord`, `PartialOrd`, `Eq`, `PartialEq`, `Hash`, `Clone`, `Copy`, `Default` -The static `Score` trait still reports three levels. The `family` field controls -presentation and host-boundary conversion. `scoped_dynamic_score_family(family, -callback)` sets the thread-local family used by `DynamicScore::zero()` and -`DynamicScore::from_level_numbers(...)` during the callback. +Every family shares one three-level value: unused levels stay zero and +`HardSoftDecimal` levels hold values scaled by 100 000. Equality, ordering, and +hashing compare the levels only, and `zero()`, `from_level_numbers(...)`, and +the arithmetic never read a family, so scores computed on any thread agree. + +### Family scope + +```rust +pub fn scoped_dynamic_score_family( + family: DynamicScoreFamily, + callback: impl FnOnce() -> T, +) -> T; + +pub fn active_dynamic_score_family() -> DynamicScoreFamily; +``` -The family is per thread. The solve thread enters it for the whole solve, but -parallel local-search evaluation scores evaluator replicas on Rayon worker -threads, so a binding's constraint set that builds `DynamicScore` values must -also enter its declared family inside its own `ConstraintSet` methods. +The declared family is a property of the solve, held per thread. It decides +presentation and parsing only: `Display` renders the active family's levels, +and `ParseableScore::parse` reads the active family's units, scaling +whole-number parts under `HardSoftDecimal` so parsing inverts `Display`. +Bindings enter the family around the solve; parallel evaluation's worker +threads need no scope. ## Public Functions diff --git a/crates/solverforge-bridge/src/lib.rs b/crates/solverforge-bridge/src/lib.rs index a2c3c39a..0578b3b0 100644 --- a/crates/solverforge-bridge/src/lib.rs +++ b/crates/solverforge-bridge/src/lib.rs @@ -19,7 +19,9 @@ pub use backend::{ }; pub use ids::{EntityClassId, ProblemFactClassId, VariableId}; pub use runner::try_run_dynamic_solver_with_config_parts; -pub use score::{scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily}; +pub use score::{ + active_dynamic_score_family, scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily, +}; pub use slots::{DynamicListVariableSlot, DynamicScalarVariableSlot}; #[cfg(test)] diff --git a/crates/solverforge-bridge/src/score.rs b/crates/solverforge-bridge/src/score.rs index aadf7e33..cea2352e 100644 --- a/crates/solverforge-bridge/src/score.rs +++ b/crates/solverforge-bridge/src/score.rs @@ -1,7 +1,6 @@ //! Dynamic score support for binding models. use std::cell::Cell; -use std::cmp::Ordering; use std::fmt; use std::ops::{Add, Neg, Sub}; @@ -24,14 +23,12 @@ thread_local! { /// Runs `callback` with a thread-local dynamic score family. /// -/// This keeps the internal static three-level score contract available to the -/// solver while allowing binding-driven solves to present zero and derived -/// scores with the host model's declared 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 builds `DynamicScore` values must also enter its -/// declared family inside its own `ConstraintSet` methods. +/// The family decides only how scores are presented and parsed: `Display` +/// renders the family's levels and `ParseableScore::parse` reads the family's +/// units. Score values and arithmetic do not depend on it, so scoring on any +/// thread, including parallel evaluation's worker threads, needs no scope. +/// Bindings enter the declared family around the solve so its output and +/// configured score limits use the host model's family. pub fn scoped_dynamic_score_family( family: DynamicScoreFamily, callback: impl FnOnce() -> T, @@ -45,7 +42,8 @@ pub fn scoped_dynamic_score_family( }) } -fn active_score_family() -> DynamicScoreFamily { +/// The family the calling thread presents and parses scores with. +pub fn active_dynamic_score_family() -> DynamicScoreFamily { ACTIVE_SCORE_FAMILY.with(Cell::get) } @@ -62,73 +60,31 @@ impl Drop for ActiveScoreFamilyGuard<'_> { /// Dynamic score used by host-language bindings. /// -/// The static `Score` trait requires a static level count. The bridge therefore -/// stores one concrete three-level representation internally and carries the -/// declared host-language family for presentation and host-boundary conversion. -/// Binding crates must validate that a solve uses one declared family -/// consistently. -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +/// The static `Score` trait requires a static level count, so every family +/// shares one three-level value: unused levels stay zero, and +/// `HardSoftDecimal` levels hold values scaled by 100 000. Equality, ordering, +/// and hashing compare the levels only. The declared family is a property of +/// the solve, not of the value; see [`scoped_dynamic_score_family`]. +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct DynamicScore { pub hard: i64, pub medium: i64, pub soft: i64, - pub family: DynamicScoreFamily, } impl DynamicScore { - pub const ZERO: Self = Self { - hard: 0, - medium: 0, - soft: 0, - family: DynamicScoreFamily::HardMediumSoft, - }; + pub const ZERO: Self = Self::of(0, 0, 0); pub const fn of(hard: i64, medium: i64, soft: i64) -> Self { - Self { - hard, - medium, - soft, - family: DynamicScoreFamily::HardMediumSoft, - } - } - - pub const fn with_family( - hard: i64, - medium: i64, - soft: i64, - family: DynamicScoreFamily, - ) -> Self { - Self { - hard, - medium, - soft, - family, - } + Self { hard, medium, soft } } pub const fn soft(soft: i64) -> Self { - Self::with_family(0, 0, soft, DynamicScoreFamily::Soft) + Self::of(0, 0, soft) } pub const fn hard_soft(hard: i64, soft: i64) -> Self { - Self::with_family(hard, 0, soft, DynamicScoreFamily::HardSoft) - } - - pub const fn hard_soft_decimal(hard_scaled: i64, soft_scaled: i64) -> Self { - Self::with_family( - hard_scaled, - 0, - soft_scaled, - DynamicScoreFamily::HardSoftDecimal, - ) - } - - pub const fn hard_medium_soft(hard: i64, medium: i64, soft: i64) -> Self { - Self::with_family(hard, medium, soft, DynamicScoreFamily::HardMediumSoft) - } - - pub const fn zero_for_family(family: DynamicScoreFamily) -> Self { - Self::with_family(0, 0, 0, family) + Self::of(hard, 0, soft) } pub fn family_levels(self, family: DynamicScoreFamily) -> Vec { @@ -140,38 +96,21 @@ impl DynamicScore { DynamicScoreFamily::HardMediumSoft => vec![self.hard, self.medium, self.soft], } } - - fn is_zero(self) -> bool { - self.hard == 0 && self.medium == 0 && self.soft == 0 - } - - fn combined_family(self, rhs: Self) -> DynamicScoreFamily { - if self.family == rhs.family { - return self.family; - } - if self.is_zero() { - return rhs.family; - } - if rhs.is_zero() { - return self.family; - } - DynamicScoreFamily::HardMediumSoft - } } impl fmt::Debug for DynamicScore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "DynamicScore({}, {}, {}, {:?})", - self.hard, self.medium, self.soft, self.family + "DynamicScore({}, {}, {})", + self.hard, self.medium, self.soft ) } } impl fmt::Display for DynamicScore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.family { + match active_dynamic_score_family() { DynamicScoreFamily::Soft => write!(f, "{}", self.soft), DynamicScoreFamily::HardSoft => write!(f, "{}hard/{}soft", self.hard, self.soft), DynamicScoreFamily::HardSoftDecimal => write!( @@ -189,27 +128,14 @@ impl fmt::Display for DynamicScore { } } -impl Ord for DynamicScore { - fn cmp(&self, other: &Self) -> Ordering { - (self.hard, self.medium, self.soft).cmp(&(other.hard, other.medium, other.soft)) - } -} - -impl PartialOrd for DynamicScore { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl Add for DynamicScore { type Output = Self; fn add(self, rhs: Self) -> Self::Output { - Self::with_family( + Self::of( self.hard + rhs.hard, self.medium + rhs.medium, self.soft + rhs.soft, - self.combined_family(rhs), ) } } @@ -218,11 +144,10 @@ impl Sub for DynamicScore { type Output = Self; fn sub(self, rhs: Self) -> Self::Output { - Self::with_family( + Self::of( self.hard - rhs.hard, self.medium - rhs.medium, self.soft - rhs.soft, - self.combined_family(rhs), ) } } @@ -231,7 +156,7 @@ impl Neg for DynamicScore { type Output = Self; fn neg(self) -> Self::Output { - Self::with_family(-self.hard, -self.medium, -self.soft, self.family) + Self::of(-self.hard, -self.medium, -self.soft) } } @@ -241,7 +166,7 @@ impl Score for DynamicScore { } fn zero() -> Self { - Self::zero_for_family(active_score_family()) + Self::ZERO } fn levels_count() -> usize { @@ -258,32 +183,19 @@ impl Score for DynamicScore { } fn from_level_numbers(levels: &[i64]) -> Self { - match (active_score_family(), levels) { - (DynamicScoreFamily::Soft, [soft]) => Self::soft(*soft), - (DynamicScoreFamily::Soft, [_, _, soft]) => Self::soft(*soft), - (DynamicScoreFamily::HardSoft, [hard, soft]) => Self::hard_soft(*hard, *soft), - (DynamicScoreFamily::HardSoft, [hard, _, soft]) => Self::hard_soft(*hard, *soft), - (DynamicScoreFamily::HardSoftDecimal, [hard, soft]) => { - Self::hard_soft_decimal(*hard, *soft) - } - (DynamicScoreFamily::HardSoftDecimal, [hard, _, soft]) => { - Self::hard_soft_decimal(*hard, *soft) - } - (DynamicScoreFamily::HardMediumSoft, [soft]) => Self::soft(*soft), - (DynamicScoreFamily::HardMediumSoft, [hard, soft]) => Self::hard_soft(*hard, *soft), - (DynamicScoreFamily::HardMediumSoft, [hard, medium, soft]) => { - Self::hard_medium_soft(*hard, *medium, *soft) - } + match levels { + [soft] => Self::soft(*soft), + [hard, soft] => Self::hard_soft(*hard, *soft), + [hard, medium, soft] => Self::of(*hard, *medium, *soft), _ => panic!("DynamicScore requires 1, 2, or 3 levels"), } } fn multiply(&self, multiplicand: f64) -> Self { - Self::with_family( + Self::of( (self.hard as f64 * multiplicand).round() as i64, (self.medium as f64 * multiplicand).round() as i64, (self.soft as f64 * multiplicand).round() as i64, - self.family, ) } @@ -292,12 +204,7 @@ impl Score for DynamicScore { } fn abs(&self) -> Self { - Self::with_family( - self.hard.abs(), - self.medium.abs(), - self.soft.abs(), - self.family, - ) + Self::of(self.hard.abs(), self.medium.abs(), self.soft.abs()) } fn to_scalar(&self) -> f64 { @@ -315,59 +222,45 @@ impl Score for DynamicScore { } impl ParseableScore for DynamicScore { + /// Parses a score in the active family's units. A decimal literal + /// (`1.5soft`) is always scaled; whole-number parts are scaled too when + /// the active family is `HardSoftDecimal`, so parsing inverts `Display`. fn parse(s: &str) -> Result { - let mut hard = 0; - let mut medium = 0; - let mut soft = 0; - let mut has_hard = false; - let mut has_medium = false; - let mut has_soft = false; - let mut hard_is_decimal = false; - let mut soft_is_decimal = false; - let mut is_decimal = false; + let decimal_family = active_dynamic_score_family() == DynamicScoreFamily::HardSoftDecimal; + let mut hard = (0, false); + let mut medium = None; + let mut soft = (0, false); + let mut has_decimal_literal = false; for part in s.split('/') { if let Some(raw) = part.strip_suffix("hard") { - has_hard = true; - let (value, decimal) = parse_score_part(raw, "hard")?; - hard = value; - hard_is_decimal = decimal; - is_decimal |= decimal; + hard = parse_score_part(raw, "hard")?; + has_decimal_literal |= hard.1; } else if let Some(raw) = part.strip_suffix("medium") { - has_medium = true; let (value, decimal) = parse_score_part(raw, "medium")?; - medium = value; - is_decimal |= decimal; + medium = Some(value); + has_decimal_literal |= decimal; } else if let Some(raw) = part.strip_suffix("soft") { - has_soft = true; - let (value, decimal) = parse_score_part(raw, "soft")?; - soft = value; - soft_is_decimal = decimal; - is_decimal |= decimal; + soft = parse_score_part(raw, "soft")?; + has_decimal_literal |= soft.1; } else if let Ok(value) = part.parse::() { - soft = value; + soft = (value, false); } else { return Err(ScoreParseError { message: format!("invalid dynamic score `{s}`"), }); } } - if has_medium { - Ok(Self::hard_medium_soft(hard, medium, soft)) - } else if has_hard || has_soft { - if is_decimal { - if has_hard && !hard_is_decimal { - hard *= DECIMAL_SCALE; - } - if has_soft && !soft_is_decimal { - soft *= DECIMAL_SCALE; - } - Ok(Self::hard_soft_decimal(hard, soft)) + if let Some(medium) = medium { + return Ok(Self::of(hard.0, medium, soft.0)); + } + let scale = |(value, decimal): (i64, bool)| { + if (decimal_family || has_decimal_literal) && !decimal { + value * DECIMAL_SCALE } else { - Ok(Self::hard_soft(hard, soft)) + value } - } else { - Ok(Self::soft(soft)) - } + }; + Ok(Self::hard_soft(scale(hard), scale(soft))) } fn to_string_repr(&self) -> String { diff --git a/crates/solverforge-bridge/src/score_tests.rs b/crates/solverforge-bridge/src/score_tests.rs index 728e25e4..d6989396 100644 --- a/crates/solverforge-bridge/src/score_tests.rs +++ b/crates/solverforge-bridge/src/score_tests.rs @@ -1,12 +1,22 @@ use std::cmp::Ordering; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use solverforge_core::score::{ParseableScore, Score}; -use crate::{scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily}; +use crate::{ + active_dynamic_score_family, scoped_dynamic_score_family, DynamicScore, DynamicScoreFamily, +}; + +fn hash_of(score: DynamicScore) -> u64 { + let mut hasher = DefaultHasher::new(); + score.hash(&mut hasher); + hasher.finish() +} #[test] fn dynamic_score_keeps_static_three_level_score_contract() { - let score = DynamicScore::hard_medium_soft(-1, 2, 3); + let score = DynamicScore::of(-1, 2, 3); assert_eq!(DynamicScore::levels_count(), 3); assert!(!score.is_feasible()); @@ -19,33 +29,53 @@ fn dynamic_score_keeps_static_three_level_score_contract() { } #[test] -fn dynamic_score_displays_declared_family() { - assert_eq!(DynamicScore::soft(3).to_string(), "3"); - assert_eq!(DynamicScore::hard_soft(-1, 3).to_string(), "-1hard/3soft"); +fn dynamic_score_displays_the_active_family() { + let display = + |family, score: DynamicScore| scoped_dynamic_score_family(family, || score.to_string()); + assert_eq!( - DynamicScore::hard_soft_decimal(-100_000, 150_500).to_string(), + display(DynamicScoreFamily::Soft, DynamicScore::soft(3)), + "3" + ); + assert_eq!( + display(DynamicScoreFamily::HardSoft, DynamicScore::hard_soft(-1, 3)), + "-1hard/3soft" + ); + assert_eq!( + display( + DynamicScoreFamily::HardSoftDecimal, + DynamicScore::hard_soft(-100_000, 150_500) + ), "-1hard/1.505soft" ); assert_eq!( - DynamicScore::hard_medium_soft(-1, 2, 3).to_string(), + display( + DynamicScoreFamily::HardMediumSoft, + DynamicScore::of(-1, 2, 3) + ), "-1hard/2medium/3soft" ); } #[test] -fn dynamic_score_zero_uses_scoped_declared_family() { - let score = scoped_dynamic_score_family(DynamicScoreFamily::HardSoftDecimal, || { - ::zero() +fn dynamic_score_family_scope_restores_the_previous_family() { + scoped_dynamic_score_family(DynamicScoreFamily::Soft, || { + scoped_dynamic_score_family(DynamicScoreFamily::HardSoft, || { + assert_eq!(active_dynamic_score_family(), DynamicScoreFamily::HardSoft); + }); + assert_eq!(active_dynamic_score_family(), DynamicScoreFamily::Soft); }); - - assert_eq!(score.to_string(), "0hard/0soft"); + assert_eq!( + active_dynamic_score_family(), + DynamicScoreFamily::HardMediumSoft + ); } #[test] fn dynamic_score_parses_standard_score_strings() { assert_eq!( DynamicScore::parse("-1hard/2medium/3soft").unwrap(), - DynamicScore::hard_medium_soft(-1, 2, 3) + DynamicScore::of(-1, 2, 3) ); assert_eq!( DynamicScore::parse("-1hard/3soft").unwrap(), @@ -53,7 +83,7 @@ fn dynamic_score_parses_standard_score_strings() { ); assert_eq!( DynamicScore::parse("-1hard/1.505soft").unwrap(), - DynamicScore::hard_soft_decimal(-100_000, 150_500) + DynamicScore::hard_soft(-100_000, 150_500) ); assert_eq!(DynamicScore::parse("3").unwrap(), DynamicScore::soft(3)); } @@ -64,6 +94,8 @@ fn dynamic_score_equality_agrees_with_ordering() { assert_eq!(declared.cmp(&DynamicScore::ZERO), Ordering::Equal); assert_eq!(declared, DynamicScore::ZERO); + assert_eq!(hash_of(declared), hash_of(DynamicScore::ZERO)); + assert!(DynamicScore::of(0, 0, 0) > DynamicScore::of(-1, 100, 100)); } #[test] From d1cc3e8c9ded1cf18f26d58cd9bf34d035812b4d Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:44:20 +0100 Subject: [PATCH 24/30] test(solverforge): pin list-variable parallel evaluation against the 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. --- .../tests/parallel_list_local_search.rs | 153 ++++++++++++++++++ .../parallel_list_local_search/domain/mod.rs | 11 ++ .../parallel_list_local_search/domain/plan.rs | 85 ++++++++++ .../domain/route.rs | 10 ++ .../domain/visit.rs | 12 ++ 5 files changed, 271 insertions(+) create mode 100644 crates/solverforge/tests/parallel_list_local_search.rs create mode 100644 crates/solverforge/tests/parallel_list_local_search/domain/mod.rs create mode 100644 crates/solverforge/tests/parallel_list_local_search/domain/plan.rs create mode 100644 crates/solverforge/tests/parallel_list_local_search/domain/route.rs create mode 100644 crates/solverforge/tests/parallel_list_local_search/domain/visit.rs diff --git a/crates/solverforge/tests/parallel_list_local_search.rs b/crates/solverforge/tests/parallel_list_local_search.rs new file mode 100644 index 00000000..cff7dfe3 --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search.rs @@ -0,0 +1,153 @@ +use solverforge::{ + AppliedMoveTelemetry, CandidateTraceDisposition, SolverEvent, SolverManager, SolverTelemetry, + SolverTerminalReason, +}; + +#[path = "parallel_list_local_search/domain/mod.rs"] +mod domain; + +use domain::{Route, RoutePlan, Visit}; + +fn seeded_plan(move_threads: usize) -> RoutePlan { + let routes = (0..3) + .map(|id| Route { + id, + visits: Vec::new(), + }) + .collect(); + let visits = (0..18) + .map(|id| Visit { + id, + demand: (id as i64 * 5 + 2) % 9, + route: None, + index: None, + previous: None, + next: None, + }) + .collect(); + RoutePlan { + routes, + visits, + score: None, + move_threads, + } +} + +struct Run { + plan: RoutePlan, + telemetry: SolverTelemetry, + consumed_pulls: Vec, +} + +fn solve(manager: &'static SolverManager, move_threads: usize) -> Run { + let (job_id, mut receiver) = manager + .solve(seeded_plan(move_threads)) + .expect("job should start"); + let mut completed = None; + while let Some(event) = receiver.blocking_recv() { + if let SolverEvent::Completed { metadata, solution } = event { + assert_eq!( + metadata.terminal_reason, + Some(SolverTerminalReason::Completed) + ); + completed = Some((solution, metadata.telemetry)); + break; + } + } + let (plan, telemetry) = completed.expect("expected a completed solution"); + let trace = manager + .get_telemetry_detail(job_id) + .expect("completed job keeps its telemetry detail") + .candidate_trace + .expect("the fixture enables the candidate trace"); + assert!( + !trace.truncated, + "the trace cap must cover the whole solve: {} pulls", + trace.total_pulls + ); + // Speculative pulls are the only legitimate difference between thread + // counts: every candidate the coordinator consumed, and what it decided + // about it, must match the sequential run in order. + let consumed_pulls = trace + .pulls + .iter() + .filter(|pull| { + !pull + .dispositions + .contains(&CandidateTraceDisposition::DiscardedSpeculative) + }) + .map(|pull| { + format!( + "{}:{}:{:?}:{:?}", + pull.phase_index, pull.step_index, pull.identity, pull.dispositions + ) + }) + .collect(); + manager.delete(job_id).expect("delete completed job"); + Run { + plan, + telemetry, + consumed_pulls, + } +} + +fn selected_moves(trace: &[AppliedMoveTelemetry]) -> Vec<(u64, &'static str, usize, f64)> { + trace + .iter() + .map(|applied| { + ( + applied.step_index, + applied.move_label, + applied.selected_candidate_index, + applied.score_after, + ) + }) + .collect() +} + +fn assert_shadows_follow_routes(plan: &RoutePlan) { + for route in &plan.routes { + for (position, &visit_id) in route.visits.iter().enumerate() { + let visit = &plan.visits[visit_id]; + assert_eq!(visit.route, Some(route.id)); + assert_eq!(visit.index, Some(position)); + assert_eq!( + visit.previous, + position.checked_sub(1).map(|prev| route.visits[prev]) + ); + assert_eq!(visit.next, route.visits.get(position + 1).copied()); + } + } +} + +#[test] +fn list_trajectory_and_shadows_are_identical_across_thread_counts() { + static MANAGER: SolverManager = SolverManager::new(); + + let runs: Vec<_> = [1, 2, 4] + .into_iter() + .map(|move_threads| solve(&MANAGER, move_threads)) + .collect(); + let baseline = &runs[0]; + assert!(baseline.telemetry.moves_applied > 0); + assert!(!baseline.consumed_pulls.is_empty()); + assert_shadows_follow_routes(&baseline.plan); + + for run in &runs[1..] { + assert_eq!( + selected_moves(&run.telemetry.applied_move_trace), + selected_moves(&baseline.telemetry.applied_move_trace) + ); + assert_eq!(run.consumed_pulls, baseline.consumed_pulls); + let routes: Vec<_> = run.plan.routes.iter().map(|route| &route.visits).collect(); + let baseline_routes: Vec<_> = baseline + .plan + .routes + .iter() + .map(|route| &route.visits) + .collect(); + assert_eq!(routes, baseline_routes); + assert_eq!(run.plan.score, baseline.plan.score); + assert_shadows_follow_routes(&run.plan); + } +} diff --git a/crates/solverforge/tests/parallel_list_local_search/domain/mod.rs b/crates/solverforge/tests/parallel_list_local_search/domain/mod.rs new file mode 100644 index 00000000..4c39c4e1 --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search/domain/mod.rs @@ -0,0 +1,11 @@ +solverforge::planning_model! { + root = "crates/solverforge/tests/parallel_list_local_search/domain"; + + mod plan; + mod route; + mod visit; + + pub use plan::RoutePlan; + pub use route::Route; + pub use visit::Visit; +} diff --git a/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs b/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs new file mode 100644 index 00000000..8827e623 --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs @@ -0,0 +1,85 @@ +use solverforge::prelude::*; +use solverforge::stream::ConstraintFactory; +use solverforge::{MoveThreadCount, SolverConfig}; + +use super::{Route, Visit}; + +#[planning_solution(constraints = "define_constraints", config = "parallel_config")] +#[shadow_variable_updates( + list_owner = "routes", + inverse_field = "route", + index_field = "index", + previous_field = "previous", + next_field = "next" +)] +pub struct RoutePlan { + #[planning_entity_collection] + pub routes: Vec, + + #[problem_fact_collection] + pub visits: Vec, + + #[planning_score] + pub score: Option, + + pub move_threads: usize, +} + +// Streams over the visits do not observe their list shadows, so every term +// reads the owning route. Position-dependent costs make order inside a route +// matter as well as ownership, so list change, swap, sublist, reversal, and +// k-opt moves all compete. +fn define_constraints() -> impl ConstraintSet { + ( + ConstraintFactory::::new() + .for_each(RoutePlan::routes()) + .penalize(|route: &Route| { + let cost = route + .visits + .iter() + .enumerate() + .map(|(position, &visit)| (visit as i64 * (position as i64 + 3)) % 11) + .sum(); + HardSoftScore::of_soft(cost) + }) + .named("position cost"), + ConstraintFactory::::new() + .for_each(RoutePlan::routes()) + .penalize(|route: &Route| { + let legs = route + .visits + .windows(2) + .map(|leg| (leg[0] as i64 - leg[1] as i64).abs() % 7) + .sum(); + HardSoftScore::of_soft(legs) + }) + .named("leg cost"), + ConstraintFactory::::new() + .for_each(RoutePlan::routes()) + .penalize(|route: &Route| HardSoftScore::of_soft((route.visits.len() as i64).pow(2))) + .named("balance"), + ) +} + +const PARALLEL_SOLVER_TOML: &str = r#" +random_seed = 11 + +[candidate_trace] +max_entries = 1_000_000 + +[[phases]] +type = "construction_heuristic" + +[[phases]] +type = "local_search" + +[phases.termination] +step_count_limit = 40 +"#; + +fn parallel_config(solution: &RoutePlan, _base: SolverConfig) -> SolverConfig { + let mut config = SolverConfig::from_toml_str(PARALLEL_SOLVER_TOML) + .expect("parallel list local-search fixture config must parse"); + config.move_thread_count = MoveThreadCount::Count(solution.move_threads); + config +} diff --git a/crates/solverforge/tests/parallel_list_local_search/domain/route.rs b/crates/solverforge/tests/parallel_list_local_search/domain/route.rs new file mode 100644 index 00000000..5f917b5e --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search/domain/route.rs @@ -0,0 +1,10 @@ +use solverforge::prelude::*; + +#[planning_entity] +pub struct Route { + #[planning_id] + pub id: usize, + + #[planning_list_variable(element_collection = "visits")] + pub visits: Vec, +} diff --git a/crates/solverforge/tests/parallel_list_local_search/domain/visit.rs b/crates/solverforge/tests/parallel_list_local_search/domain/visit.rs new file mode 100644 index 00000000..eaf35445 --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search/domain/visit.rs @@ -0,0 +1,12 @@ +use solverforge::prelude::*; + +#[problem_fact] +pub struct Visit { + #[planning_id] + pub id: usize, + pub demand: i64, + pub route: Option, + pub index: Option, + pub previous: Option, + pub next: Option, +} From efbfc48e592f676b776d0c6380dacc5fccd2e5a4 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 22:48:04 +0100 Subject: [PATCH 25/30] test(solverforge): pin incremental scoring of streams that read list 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. --- .../tests/parallel_list_local_search.rs | 21 ++++++++++ .../parallel_list_local_search/domain/plan.rs | 41 +++++++++---------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/solverforge/tests/parallel_list_local_search.rs b/crates/solverforge/tests/parallel_list_local_search.rs index cff7dfe3..aead2513 100644 --- a/crates/solverforge/tests/parallel_list_local_search.rs +++ b/crates/solverforge/tests/parallel_list_local_search.rs @@ -151,3 +151,24 @@ fn list_trajectory_and_shadows_are_identical_across_thread_counts() { assert_shadows_follow_routes(&run.plan); } } + +#[test] +fn incremental_score_tracks_list_shadows_read_by_element_streams() { + static MANAGER: SolverManager = SolverManager::new(); + + for move_threads in [1, 2] { + let run = solve(&MANAGER, move_threads); + assert_shadows_follow_routes(&run.plan); + // Every visit is routed, so the shadow-reading hard term is zero. + assert_eq!( + run.plan.score.map(|score| score.hard()), + Some(0), + "move_threads = {move_threads}" + ); + assert_eq!( + run.plan.score, + Some(solverforge::analyze(&run.plan).score), + "move_threads = {move_threads}" + ); + } +} diff --git a/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs b/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs index 8827e623..c21d761b 100644 --- a/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs +++ b/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs @@ -25,33 +25,32 @@ pub struct RoutePlan { pub move_threads: usize, } -// Streams over the visits do not observe their list shadows, so every term -// reads the owning route. Position-dependent costs make order inside a route -// matter as well as ownership, so list change, swap, sublist, reversal, and -// k-opt moves all compete. +// The visit terms read only list shadows, so a director whose element rows +// lag their shadows scores every candidate wrong. Position-dependent costs +// make order inside a route matter as well as ownership, so list change, +// swap, sublist, reversal, and k-opt moves all compete. fn define_constraints() -> impl ConstraintSet { ( ConstraintFactory::::new() - .for_each(RoutePlan::routes()) - .penalize(|route: &Route| { - let cost = route - .visits - .iter() - .enumerate() - .map(|(position, &visit)| (visit as i64 * (position as i64 + 3)) % 11) - .sum(); - HardSoftScore::of_soft(cost) + .for_each(RoutePlan::visits()) + .filter(|visit: &Visit| visit.route.is_none()) + .penalize(HardSoftScore::ONE_HARD) + .named("unrouted visit"), + ConstraintFactory::::new() + .for_each(RoutePlan::visits()) + .filter(|visit: &Visit| visit.index.is_some()) + .penalize(|visit: &Visit| { + let index = visit.index.unwrap_or_default() as i64; + let route = visit.route.unwrap_or_default() as i64; + HardSoftScore::of_soft((visit.demand * (index + 2) + route * 5) % 13) }) .named("position cost"), ConstraintFactory::::new() - .for_each(RoutePlan::routes()) - .penalize(|route: &Route| { - let legs = route - .visits - .windows(2) - .map(|leg| (leg[0] as i64 - leg[1] as i64).abs() % 7) - .sum(); - HardSoftScore::of_soft(legs) + .for_each(RoutePlan::visits()) + .filter(|visit: &Visit| visit.previous.is_some()) + .penalize(|visit: &Visit| { + let previous = visit.previous.unwrap_or_default() as i64; + HardSoftScore::of_soft((previous - visit.id as i64).abs() % 7) }) .named("leg cost"), ConstraintFactory::::new() From c0b5b980d95017403c190db88d04ad5de3b7b67c Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 23:01:46 +0100 Subject: [PATCH 26/30] feat(core): let solutions report the elements their list shadows write 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. --- crates/solverforge-core/WIREFRAME.md | 3 ++ crates/solverforge-core/src/domain/traits.rs | 29 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/solverforge-core/WIREFRAME.md b/crates/solverforge-core/WIREFRAME.md index f9a53a2e..c0d5b5c1 100644 --- a/crates/solverforge-core/WIREFRAME.md +++ b/crates/solverforge-core/WIREFRAME.md @@ -142,6 +142,9 @@ Additionally, `pub mod constraint`, `pub mod domain`, `pub mod error`, `pub mod | `set_score` | `fn set_score(&mut self, score: Option)` | Set the score | | `update_entity_shadows` | `fn update_entity_shadows(&mut self, descriptor_index: usize, entity_index: usize)` | Default: no-op shadow update hook | | `update_all_shadows` | `fn update_all_shadows(&mut self)` | Default: no-op full shadow refresh hook | +| `list_shadow_elements` | `fn list_shadow_elements(&self, descriptor_index: usize, entity_index: usize) -> Option<(usize, &[usize])>` | Default: `None`; the elements an owner's shadow update writes, with the change source their stream rows use | +| `clear_list_shadows` | `fn clear_list_shadows(&mut self, element: usize)` | Default: no-op; clears the list shadows of an element no list holds | +| `list_shadow_source` | `fn list_shadow_source(collection: &str) -> Option` | Default: `None`; change source of a collection whose rows carry list shadows | | `is_initialized` | `fn is_initialized(&self) -> bool` | Default: `true` | **Associated type:** `type Score: Score` diff --git a/crates/solverforge-core/src/domain/traits.rs b/crates/solverforge-core/src/domain/traits.rs index fc470b9c..d9691425 100644 --- a/crates/solverforge-core/src/domain/traits.rs +++ b/crates/solverforge-core/src/domain/traits.rs @@ -69,6 +69,35 @@ pub trait PlanningSolution: Clone + Send + Sync + 'static { */ fn update_all_shadows(&mut self) {} + /* Returns the elements whose list shadows + `update_entity_shadows(descriptor_index, entity_index)` writes, with the + change source their stream rows are reported under. + + A score director retracts these rows before the shadow write and inserts + them after it, so streams that read element shadows stay incremental. + Default implementation reports no list shadows. + */ + fn list_shadow_elements( + &self, + _descriptor_index: usize, + _entity_index: usize, + ) -> Option<(usize, &[usize])> { + None + } + + /* Clears the list shadows of an element that no list holds any more. + + Default implementation is a no-op. + */ + fn clear_list_shadows(&mut self, _element: usize) {} + + /* Returns the change source under which rows of `collection` are reported + when they carry list shadows, or `None` when the collection holds none. + */ + fn list_shadow_source(_collection: &str) -> Option { + None + } + /* Returns true if this solution is fully initialized. A solution is initialized when all planning variables have been assigned. From 090df3b187dff656e17f7295d1689de2dd64ef81 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 23:01:46 +0100 Subject: [PATCH 27/30] test(scoring): pin element rows that read list shadows against fresh 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. --- .../src/director/tests/list_shadows.rs | 205 ++++++++++++++++++ .../src/director/tests/mod.rs | 1 + 2 files changed, 206 insertions(+) create mode 100644 crates/solverforge-scoring/src/director/tests/list_shadows.rs diff --git a/crates/solverforge-scoring/src/director/tests/list_shadows.rs b/crates/solverforge-scoring/src/director/tests/list_shadows.rs new file mode 100644 index 00000000..d2ddc142 --- /dev/null +++ b/crates/solverforge-scoring/src/director/tests/list_shadows.rs @@ -0,0 +1,205 @@ +// Element streams that read list shadows, scored through ScoreDirector. + +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::SoftScore; +use solverforge_core::{ConstraintRef, ImpactType}; + +use crate::api::constraint_set::IncrementalConstraint; +use crate::constraint::incremental::IncrementalUniConstraint; +use crate::director::score_director::ScoreDirector; +use crate::stream::collection_extract::{source, ChangeSource, SourceExtract}; + +const ROUTES: usize = 0; +const STOPS: usize = 1; + +#[derive(Clone, Debug, Default)] +struct Stop { + weight: i64, + route: Option, + index: Option, +} + +#[derive(Clone, Debug)] +struct Plan { + routes: Vec>, + stops: Vec, + score: Option, +} + +impl PlanningSolution for Plan { + type Score = SoftScore; + + fn score(&self) -> Option { + self.score + } + + fn set_score(&mut self, score: Option) { + self.score = score; + } + + fn update_entity_shadows(&mut self, descriptor_index: usize, entity_index: usize) { + if descriptor_index != ROUTES { + return; + } + for (position, &stop) in self.routes[entity_index].iter().enumerate() { + self.stops[stop].route = Some(entity_index); + self.stops[stop].index = Some(position); + } + } + + fn update_all_shadows(&mut self) { + for stop in 0..self.stops.len() { + self.clear_list_shadows(stop); + } + for route in 0..self.routes.len() { + self.update_entity_shadows(ROUTES, route); + } + } + + fn list_shadow_elements( + &self, + descriptor_index: usize, + entity_index: usize, + ) -> Option<(usize, &[usize])> { + (descriptor_index == ROUTES).then(|| (STOPS, self.routes[entity_index].as_slice())) + } + + fn clear_list_shadows(&mut self, element: usize) { + self.stops[element].route = None; + self.stops[element].index = None; + } +} + +type StopCost = IncrementalUniConstraint< + Plan, + Stop, + SourceExtract &[Stop]>, + fn(&Plan, &Stop) -> bool, + fn(&Stop) -> SoftScore, + SoftScore, +>; + +// Unrouted stops cost 10; routed ones cost by route and weighted position, +// so any reordering changes the total. +fn stop_cost() -> StopCost { + fn stops(plan: &Plan) -> &[Stop] { + &plan.stops + } + fn every(_: &Plan, _: &Stop) -> bool { + true + } + fn cost(stop: &Stop) -> SoftScore { + match (stop.route, stop.index) { + (Some(route), Some(index)) => { + SoftScore::of(route as i64 * 3 + stop.weight * (index as i64 + 1)) + } + _ => SoftScore::of(10), + } + } + IncrementalUniConstraint::new( + ConstraintRef::new("", "stop cost"), + ImpactType::Penalty, + source( + stops as fn(&Plan) -> &[Stop], + ChangeSource::Descriptor(STOPS), + ), + every as fn(&Plan, &Stop) -> bool, + cost as fn(&Stop) -> SoftScore, + false, + ) +} + +fn director(routes: Vec>, stops: usize) -> ScoreDirector { + let plan = Plan { + routes, + stops: (0..stops) + .map(|stop| Stop { + weight: stop as i64 * 2 + 1, + ..Stop::default() + }) + .collect(), + score: None, + }; + let mut director = ScoreDirector::new(plan, (stop_cost(),)); + director.calculate_score(); + director +} + +fn assert_matches_fresh(director: &mut ScoreDirector) { + let mut fresh = director.working_solution().clone(); + fresh.update_all_shadows(); + assert_eq!( + director.calculate_score(), + stop_cost().evaluate(&fresh), + "routes {:?}", + director.working_solution().routes + ); +} + +// Moves `stop` from `from` to the end of `to`, notifying owners in `order`. +fn relocate( + director: &mut ScoreDirector, + stop: usize, + from: Option, + to: Option, + order: &[usize], +) { + for &owner in order { + director.before_variable_changed(ROUTES, owner); + } + let routes = &mut director.working_solution_mut().routes; + if let Some(from) = from { + routes[from].retain(|&held| held != stop); + } + if let Some(to) = to { + routes[to].push(stop); + } + for &owner in order { + director.after_variable_changed(ROUTES, owner); + } +} + +#[test] +fn rows_follow_stops_moved_between_routes_in_either_notification_order() { + let mut director = director(vec![vec![0, 1, 2], vec![3]], 4); + assert_matches_fresh(&mut director); + + relocate(&mut director, 1, Some(0), Some(1), &[0, 1]); + assert_matches_fresh(&mut director); + + relocate(&mut director, 3, Some(1), Some(0), &[0, 1]); + assert_matches_fresh(&mut director); + + relocate(&mut director, 0, Some(0), Some(1), &[1, 0]); + assert_matches_fresh(&mut director); +} + +#[test] +fn rows_follow_stops_that_leave_and_rejoin_every_route() { + let mut director = director(vec![vec![0, 1], vec![]], 3); + assert_matches_fresh(&mut director); + + // Stop 2 starts unrouted and joins a route. + relocate(&mut director, 2, None, Some(1), &[1]); + assert_matches_fresh(&mut director); + + // Stop 0 leaves every route: its shadows clear and the remaining stop + // shifts position. + relocate(&mut director, 0, Some(0), None, &[0]); + assert!(director.working_solution().stops[0].route.is_none()); + assert_matches_fresh(&mut director); + + relocate(&mut director, 0, None, Some(0), &[0]); + assert_matches_fresh(&mut director); +} + +#[test] +fn reordering_inside_one_route_rescores_every_shifted_stop() { + let mut director = director(vec![vec![0, 1, 2, 3]], 4); + + director.before_variable_changed(ROUTES, 0); + director.working_solution_mut().routes[0].reverse(); + director.after_variable_changed(ROUTES, 0); + + assert_matches_fresh(&mut director); +} diff --git a/crates/solverforge-scoring/src/director/tests/mod.rs b/crates/solverforge-scoring/src/director/tests/mod.rs index 4f087747..24cec46a 100644 --- a/crates/solverforge-scoring/src/director/tests/mod.rs +++ b/crates/solverforge-scoring/src/director/tests/mod.rs @@ -1,4 +1,5 @@ mod bench; mod fixtures; +mod list_shadows; mod score_director; mod shadow; From 3795a1c2d14e874373decd06ea79560cdaf9be66 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 23:01:51 +0100 Subject: [PATCH 28/30] fix(scoring)!: score list-shadow writes as element mutations 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. --- crates/solverforge-macros/WIREFRAME.md | 16 +- .../src/planning_model/shadows.rs | 78 +++++++++- .../src/planning_solution/shadow.rs | 26 ++++ .../planning_solution/stream_extensions.rs | 22 ++- crates/solverforge-scoring/WIREFRAME.md | 10 ++ .../src/director/score_director.rs | 1 + .../director/score_director/incremental.rs | 24 ++- .../director/score_director/list_shadows.rs | 140 ++++++++++++++++++ .../solverforge-solver/src/model_support.rs | 14 ++ 9 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 crates/solverforge-scoring/src/director/score_director/list_shadows.rs diff --git a/crates/solverforge-macros/WIREFRAME.md b/crates/solverforge-macros/WIREFRAME.md index 8f947581..091dda3a 100644 --- a/crates/solverforge-macros/WIREFRAME.md +++ b/crates/solverforge-macros/WIREFRAME.md @@ -247,7 +247,7 @@ is rejected unless the override repeats the exact stock path. Custom routing semantics should omit `domain = "cvrp"` and declare explicit hook paths instead. **Generated code:** -- `impl PlanningSolution for T` — `type Score`, `score()`, `set_score()`, plus `update_entity_shadows()` / `update_all_shadows()` delegation to the manifest-owned support implementation when `#[shadow_variable_updates(...)]` configures list shadows. +- `impl PlanningSolution for T` — `type Score`, `score()`, `set_score()`, plus `update_entity_shadows()` / `update_all_shadows()` / `list_shadow_elements()` / `clear_list_shadows()` / `list_shadow_source()` delegation to the manifest-owned support implementation when `#[shadow_variable_updates(...)]` configures list shadows. - `impl T { pub fn descriptor() -> SolutionDescriptor }` — builds full descriptor with entity extractors and fact extractors, reusing entity-generated descriptors so field-level variable order and metadata are preserved - `impl T { pub fn entity_count(&Self, descriptor_index: usize) -> usize }` — entity count by descriptor index - Private owner-specific list operations used by the canonical runtime: `__solverforge_list_len_()`, `__solverforge_list_remove_()`, `__solverforge_list_insert_()`, `__solverforge_list_get_()`, `__solverforge_list_set_()`, `__solverforge_list_reverse_()`, `__solverforge_sublist_remove_()`, `__solverforge_sublist_insert_()`, `__solverforge_ruin_remove_()`, `__solverforge_ruin_insert_()`, `__solverforge_list_remove_for_construction_()`, `__solverforge_index_to_element_()`, `__solverforge_element_source_key_()`, `__solverforge_element_count_()`, `__solverforge_assigned_elements_()`, `__solverforge_n_entities_()`, `__solverforge_assign_element_()`, plus aggregate helpers `__solverforge_total_list_entities()` and `__solverforge_total_list_elements()` @@ -352,7 +352,19 @@ When configured through `#[shadow_variable_updates]`, `update_entity_shadows(descriptor_index, entity_idx)` collects the list owner's `element_indices`, then updates inverse, index, previous, next, cascading, aggregate, compute, and post-update fields in that order. `update_all_shadows()` -visits the configured list owner once. +clears the configured inverse, index, previous, and next fields of every +element, then visits the configured list owner once. + +When inverse, index, previous, next, or cascading updates write element +fields, the generated support also reports them to the score director: +`list_shadow_elements()` returns the owner's elements with their change +source, and `clear_list_shadows()` clears an element that left every list. +The change source is the element collection's descriptor index when it is a +`#[planning_entity_collection]`, otherwise the first index past the entity +descriptors. Stream sources of `#[problem_fact_collection]` and +`#[planning_list_element_collection]` fields ask `list_shadow_source()` and +react to that source instead of staying static, so constraints that read +element shadows score incrementally. ## Test Coverage diff --git a/crates/solverforge-macros/src/planning_model/shadows.rs b/crates/solverforge-macros/src/planning_model/shadows.rs index 50041e2b..da2f61bc 100644 --- a/crates/solverforge-macros/src/planning_model/shadows.rs +++ b/crates/solverforge-macros/src/planning_model/shadows.rs @@ -2,6 +2,8 @@ struct GeneratedListShadowUpdate { update: TokenStream, reset: TokenStream, descriptor: Option<(usize, Ident)>, + // Element reporting, present when the update writes element fields. + elements: TokenStream, } fn generate_shadow_methods(model: &ModelMetadata) -> Result { @@ -11,6 +13,7 @@ fn generate_shadow_methods(model: &ModelMetadata) -> Result { let generated = generate_list_shadow_update(model, &solution_path)?; let list_update = generated.update; let list_reset = generated.reset; + let list_elements = generated.elements; let Some((descriptor_index, collection_accessor)) = generated.descriptor else { return Ok(quote! { @@ -50,6 +53,8 @@ fn generate_shadow_methods(model: &ModelMetadata) -> Result { } true } + + #list_elements }) } @@ -63,6 +68,7 @@ fn generate_list_shadow_update( update: TokenStream::new(), reset: TokenStream::new(), descriptor: None, + elements: TokenStream::new(), }); } @@ -152,14 +158,20 @@ fn generate_list_shadow_update( } } }); - let index_reset = config.index_field.as_ref().map(|field| { - let field_ident = Ident::new(field, proc_macro2::Span::call_site()); + let cleared_fields: Vec = [ + &config.inverse_field, + &config.index_field, + &config.previous_field, + &config.next_field, + ] + .into_iter() + .flatten() + .map(|field| Ident::new(field, proc_macro2::Span::call_site())) + .collect(); + let element_reset = (!cleared_fields.is_empty()).then(|| { quote! { - { - let elements = #solution_path::#element_mut_accessor(solution); - for element in elements { - element.#field_ident = None; - } + for element in #solution_path::#element_mut_accessor(solution).iter_mut() { + #(element.#cleared_fields = None;)* } } }); @@ -225,10 +237,60 @@ fn generate_list_shadow_update( updated = true; } }; + /* Reports the elements an owner's shadow update writes. Their stream + rows use the element collection's own descriptor when it is an entity + collection; a fact collection is reported under the first index past the + entity descriptors, which no entity notification uses. + */ + let writes_elements = config.inverse_field.is_some() + || config.index_field.is_some() + || config.previous_field.is_some() + || config.next_field.is_some() + || config.cascading_listener.is_some(); + let element_source = element_collection.descriptor_index.unwrap_or_else(|| { + model + .solution + .collections + .iter() + .filter(|collection| collection.descriptor_index.is_some()) + .count() + }); + let element_field = &element_collection.field_name; + let elements = if writes_elements { + quote! { + fn list_shadow_elements( + solution: &Self, + descriptor_index: usize, + entity_index: usize, + ) -> ::core::option::Option<(usize, &[usize])> { + if descriptor_index != #descriptor_index { + return ::core::option::Option::None; + } + #solution_path::#owner_accessor(solution) + .get(entity_index) + .map(|owner| (#element_source, owner.#list_variable_ident.as_slice())) + } + + fn clear_list_shadows(solution: &mut Self, element: usize) { + if let ::core::option::Option::Some(element) = + #solution_path::#element_mut_accessor(solution).get_mut(element) + { + #(element.#cleared_fields = None;)* + } + } + + fn list_shadow_source(collection: &str) -> ::core::option::Option { + (collection == #element_field).then_some(#element_source) + } + } + } else { + TokenStream::new() + }; Ok(GeneratedListShadowUpdate { update, - reset: index_reset.unwrap_or_default(), + reset: element_reset.unwrap_or_default(), descriptor: Some((descriptor_index, owner_accessor)), + elements, }) } diff --git a/crates/solverforge-macros/src/planning_solution/shadow.rs b/crates/solverforge-macros/src/planning_solution/shadow.rs index 729dccd6..cd1099c5 100644 --- a/crates/solverforge-macros/src/planning_solution/shadow.rs +++ b/crates/solverforge-macros/src/planning_solution/shadow.rs @@ -47,5 +47,31 @@ pub(super) fn generate_shadow_support( self, ); } + + #[inline] + fn list_shadow_elements( + &self, + descriptor_index: usize, + entity_idx: usize, + ) -> ::core::option::Option<(usize, &[usize])> { + ::list_shadow_elements( + self, + descriptor_index, + entity_idx, + ) + } + + #[inline] + fn clear_list_shadows(&mut self, element: usize) { + ::clear_list_shadows( + self, + element, + ); + } + + #[inline] + fn list_shadow_source(collection: &str) -> ::core::option::Option { + ::list_shadow_source(collection) + } }) } diff --git a/crates/solverforge-macros/src/planning_solution/stream_extensions.rs b/crates/solverforge-macros/src/planning_solution/stream_extensions.rs index 3d2b8c61..a221553e 100644 --- a/crates/solverforge-macros/src/planning_solution/stream_extensions.rs +++ b/crates/solverforge-macros/src/planning_solution/stream_extensions.rs @@ -56,11 +56,12 @@ pub(super) fn generate_collection_source_methods( None => continue, }; + let change_source = unowned_change_source("e! { Self }, field_name); source_methods.push(quote! { pub fn #field_name() -> ::solverforge::__internal::SourceExtract &[#element_type]> { ::solverforge::__internal::source( (|s: &Self| s.#field_name.as_slice()) as fn(&Self) -> &[#element_type], - ::solverforge::__internal::ChangeSource::Static, + #change_source, ) } }); @@ -76,11 +77,12 @@ pub(super) fn generate_collection_source_methods( None => continue, }; + let change_source = unowned_change_source("e! { Self }, field_name); source_methods.push(quote! { pub fn #field_name() -> ::solverforge::__internal::SourceExtract &[#element_type]> { ::solverforge::__internal::source( (|s: &Self| s.#field_name.as_slice()) as fn(&Self) -> &[#element_type], - ::solverforge::__internal::ChangeSource::Static, + #change_source, ) } }); @@ -146,7 +148,7 @@ pub(super) fn generate_constraint_stream_extensions( Some(t) => t, None => continue, }; - let change_source = quote! { ::solverforge::__internal::ChangeSource::Static }; + let change_source = unowned_change_source("e! { #solution_name }, field_name); push_stream_extension_method( &mut trait_methods, @@ -206,3 +208,17 @@ fn push_stream_extension_method( } }); } + +/* Change source of a fact or list-element collection: static unless its +rows carry list shadows, which the score director reports under the source +the planning model assigns them. +*/ +fn unowned_change_source(solution: &TokenStream, field_name: &Ident) -> TokenStream { + let collection = field_name.to_string(); + quote! { + match <#solution as ::solverforge::__internal::PlanningSolution>::list_shadow_source(#collection) { + ::core::option::Option::Some(source) => ::solverforge::__internal::ChangeSource::Descriptor(source), + ::core::option::Option::None => ::solverforge::__internal::ChangeSource::Static, + } + } +} diff --git a/crates/solverforge-scoring/WIREFRAME.md b/crates/solverforge-scoring/WIREFRAME.md index fa2d360d..a8183dcd 100644 --- a/crates/solverforge-scoring/WIREFRAME.md +++ b/crates/solverforge-scoring/WIREFRAME.md @@ -214,6 +214,7 @@ src/ │ ├── score_director.rs — Re-exports ScoreDirector pieces │ │ ├── score_director/incremental.rs — ScoreDirector (zero-erasure incremental) │ │ ├── score_director/descriptor.rs — DirectorDescriptor, ReplicaDescriptor +│ │ ├── score_director/list_shadows.rs — Retract/insert of list-shadowed element rows around owner shadow updates │ │ └── score_director/adapters.rs — Debug and Director trait impls for ScoreDirector │ ├── shadow_aware.rs — SolvableSolution trait and shadow lifecycle notes │ └── tests/ @@ -223,6 +224,7 @@ src/ │ ├── fixtures.rs — ScoreDirector fixtures │ ├── fixtures_tests.rs — ScoreDirector fixture tests │ ├── score_director.rs — ScoreDirector tests +│ ├── list_shadows.rs — Element streams reading list shadows, against fresh evaluation │ └── shadow.rs — Shadow-aware director tests ├── stream/ @@ -423,6 +425,14 @@ Implemented for each `IncrementalConstraint` as a singleton set and for t - `update_entity_shadows(&mut self, descriptor_index: usize, entity_index: usize)` — default no-op - `update_all_shadows(&mut self)` — default no-op +`ScoreDirector` treats an owner's list shadow update as a mutation of the +elements it writes (`list_shadow_elements`). `before_variable_changed` +retracts the rows of the elements the owner holds; `after_variable_changed` +retracts rows of elements that arrived from no list, rewrites the shadows, +inserts the owner and every element it now holds, and clears and re-inserts +elements that left the owner without joining another list. Element rows +therefore always hold the shadows they were inserted with. + ### `SolvableSolution` — `: PlanningSolution` | Method | Signature | Note | diff --git a/crates/solverforge-scoring/src/director/score_director.rs b/crates/solverforge-scoring/src/director/score_director.rs index c53cc414..141fcb51 100644 --- a/crates/solverforge-scoring/src/director/score_director.rs +++ b/crates/solverforge-scoring/src/director/score_director.rs @@ -7,6 +7,7 @@ constraint sets instead of trait-object-based scoring. mod adapters; mod descriptor; mod incremental; +mod list_shadows; pub use descriptor::{DirectorDescriptor, ReplicaDescriptor}; pub use incremental::ScoreDirector; diff --git a/crates/solverforge-scoring/src/director/score_director/incremental.rs b/crates/solverforge-scoring/src/director/score_director/incremental.rs index e0ea76bc..5f0642dc 100644 --- a/crates/solverforge-scoring/src/director/score_director/incremental.rs +++ b/crates/solverforge-scoring/src/director/score_director/incremental.rs @@ -4,6 +4,7 @@ use solverforge_core::domain::{PlanningSolution, SolutionDescriptor}; use solverforge_core::score::Score; use super::descriptor::DirectorDescriptor; +use super::list_shadows::ListShadowRows; use crate::api::constraint_set::{ConstraintMetadata, ConstraintSet}; /* A zero-erasure score director for incremental scoring. @@ -75,6 +76,7 @@ where constraints: C, cached_score: S::Score, initialized: bool, + list_shadow_rows: ListShadowRows, pub(super) solution_descriptor: Desc, /* Entity counter function. @@ -141,6 +143,7 @@ where constraints, cached_score: S::Score::zero(), initialized: false, + list_shadow_rows: ListShadowRows::default(), solution_descriptor, entity_counter, _phantom: PhantomData, @@ -200,7 +203,13 @@ where let delta = self.constraints .on_retract_all(&self.working_solution, entity_index, descriptor_index); - self.cached_score = self.cached_score + delta; + let element_delta = self.list_shadow_rows.retract_held( + &self.working_solution, + &mut self.constraints, + descriptor_index, + entity_index, + ); + self.cached_score = self.cached_score + delta + element_delta; } pub(crate) fn after_variable_changed_impl( @@ -211,16 +220,18 @@ where if !self.initialized { return; } - self.working_solution - .update_entity_shadows(descriptor_index, entity_index); - let delta = - self.constraints - .on_insert_all(&self.working_solution, entity_index, descriptor_index); + let delta = self.list_shadow_rows.refresh_owner( + &mut self.working_solution, + &mut self.constraints, + descriptor_index, + entity_index, + ); self.cached_score = self.cached_score + delta; } pub(crate) fn reset_impl(&mut self) { self.constraints.reset_all(); + self.list_shadow_rows.clear(); self.initialized = false; self.cached_score = S::Score::zero(); } @@ -247,6 +258,7 @@ where self.initialized = true; } else { self.constraints.reset_all(); + self.list_shadow_rows.clear(); self.cached_score = S::Score::zero(); self.initialized = false; } diff --git a/crates/solverforge-scoring/src/director/score_director/list_shadows.rs b/crates/solverforge-scoring/src/director/score_director/list_shadows.rs new file mode 100644 index 00000000..e21d5cd1 --- /dev/null +++ b/crates/solverforge-scoring/src/director/score_director/list_shadows.rs @@ -0,0 +1,140 @@ +/* Stream rows of list-shadowed elements. + +An owner's list shadow update rewrites fields of the elements it holds, so +those writes are mutations the constraint streams must see. Every element row +is retracted while it still holds the shadows it was inserted with, and +inserted again once its shadows are final: + +- before an owner's list changes, the rows of the elements it holds are + retracted and marked pending; +- after the change, the rows of elements that arrived from no list are + retracted, the shadows are rewritten, and every element the owner now + holds is inserted; +- an element that left this owner and is still pending is held by no list + yet: its shadows are cleared and its row inserted. If another owner picks it + up afterwards, that owner retracts the cleared row like any arrival. + +Pending marks are reused across moves, so tracking allocates only while the +element count grows. +*/ + +use solverforge_core::domain::PlanningSolution; +use solverforge_core::score::Score; + +use crate::api::constraint_set::ConstraintSet; + +#[derive(Clone, Copy)] +struct PendingElement { + element: usize, + source: usize, + owner_descriptor: usize, + owner_index: usize, +} + +#[derive(Default)] +pub(super) struct ListShadowRows { + pending: Vec, + is_pending: Vec, +} + +impl ListShadowRows { + pub(super) fn clear(&mut self) { + self.pending.clear(); + self.is_pending.fill(false); + } + + fn mark(&mut self, element: usize, pending: bool) { + if element >= self.is_pending.len() { + self.is_pending.resize(element + 1, false); + } + self.is_pending[element] = pending; + } + + fn pending(&self, element: usize) -> bool { + self.is_pending.get(element).copied().unwrap_or(false) + } + + pub(super) fn retract_held( + &mut self, + solution: &S, + constraints: &mut C, + owner_descriptor: usize, + owner_index: usize, + ) -> S::Score + where + S: PlanningSolution, + C: ConstraintSet, + { + let mut delta = S::Score::zero(); + let Some((source, elements)) = solution.list_shadow_elements(owner_descriptor, owner_index) + else { + return delta; + }; + for &element in elements { + if self.pending(element) { + continue; + } + delta = delta + constraints.on_retract_all(solution, element, source); + self.mark(element, true); + self.pending.push(PendingElement { + element, + source, + owner_descriptor, + owner_index, + }); + } + delta + } + + /* Rewrites the owner's shadows between retracting its arrivals and + inserting everything it holds, and inserts the owner's own row too, so + the owner observes its refreshed aggregates. + */ + pub(super) fn refresh_owner( + &mut self, + solution: &mut S, + constraints: &mut C, + owner_descriptor: usize, + owner_index: usize, + ) -> S::Score + where + S: PlanningSolution, + C: ConstraintSet, + { + let mut delta = S::Score::zero(); + if let Some((source, elements)) = + solution.list_shadow_elements(owner_descriptor, owner_index) + { + for &element in elements { + if !self.pending(element) { + delta = delta + constraints.on_retract_all(solution, element, source); + } + } + } + solution.update_entity_shadows(owner_descriptor, owner_index); + delta = delta + constraints.on_insert_all(solution, owner_index, owner_descriptor); + if let Some((source, elements)) = + solution.list_shadow_elements(owner_descriptor, owner_index) + { + for &element in elements { + delta = delta + constraints.on_insert_all(solution, element, source); + self.mark(element, false); + } + } + let mut position = 0; + while position < self.pending.len() { + let entry = self.pending[position]; + if entry.owner_descriptor != owner_descriptor || entry.owner_index != owner_index { + position += 1; + continue; + } + self.pending.swap_remove(position); + if self.pending(entry.element) { + solution.clear_list_shadows(entry.element); + delta = delta + constraints.on_insert_all(solution, entry.element, entry.source); + self.mark(entry.element, false); + } + } + delta + } +} diff --git a/crates/solverforge-solver/src/model_support.rs b/crates/solverforge-solver/src/model_support.rs index 77339651..55cb9d1a 100644 --- a/crates/solverforge-solver/src/model_support.rs +++ b/crates/solverforge-solver/src/model_support.rs @@ -37,4 +37,18 @@ pub trait PlanningModelSupport: PlanningSolution + Sized + 'static { ) -> bool; fn update_all_shadows(solution: &mut Self) -> bool; + + fn list_shadow_elements( + _solution: &Self, + _descriptor_index: usize, + _entity_index: usize, + ) -> Option<(usize, &[usize])> { + None + } + + fn clear_list_shadows(_solution: &mut Self, _element: usize) {} + + fn list_shadow_source(_collection: &str) -> Option { + None + } } From 17d93344cb6d6cfb879a081be28db24260235111 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 23:02:48 +0100 Subject: [PATCH 29/30] test(solverforge): pin candidate-trace parity for scalar parallel evaluation 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. --- .../tests/parallel_local_search.rs | 65 +++++++++++++++++-- .../parallel_local_search/domain/plan.rs | 3 + 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/crates/solverforge/tests/parallel_local_search.rs b/crates/solverforge/tests/parallel_local_search.rs index edb59255..f488bfc6 100644 --- a/crates/solverforge/tests/parallel_local_search.rs +++ b/crates/solverforge/tests/parallel_local_search.rs @@ -1,4 +1,6 @@ -use solverforge::{SolverEvent, SolverManager, SolverTelemetry, SolverTerminalReason}; +use solverforge::{ + CandidateTraceDisposition, SolverEvent, SolverManager, SolverTelemetry, SolverTerminalReason, +}; #[path = "parallel_local_search/domain/mod.rs"] mod domain; @@ -27,7 +29,13 @@ fn seeded_plan(move_threads: usize) -> Plan { } } -fn solve(manager: &'static SolverManager, move_threads: usize) -> (Plan, SolverTelemetry) { +struct Run { + plan: Plan, + telemetry: SolverTelemetry, + consumed_pulls: Vec, +} + +fn solve(manager: &'static SolverManager, move_threads: usize) -> Run { let (job_id, mut receiver) = manager .solve(seeded_plan(move_threads)) .expect("job should start"); @@ -42,8 +50,40 @@ fn solve(manager: &'static SolverManager, move_threads: usize) -> (Plan, S break; } } + let (plan, telemetry) = completed.expect("expected a completed solution"); + let trace = manager + .get_telemetry_detail(job_id) + .expect("completed job keeps its telemetry detail") + .candidate_trace + .expect("the fixture enables the candidate trace"); + assert!( + !trace.truncated, + "the trace cap must cover the whole solve: {} pulls", + trace.total_pulls + ); + // Speculative pulls are the only legitimate difference between thread + // counts; every consumed candidate and its decisions must match in order. + let consumed_pulls = trace + .pulls + .iter() + .filter(|pull| { + !pull + .dispositions + .contains(&CandidateTraceDisposition::DiscardedSpeculative) + }) + .map(|pull| { + format!( + "{}:{}:{:?}:{:?}", + pull.phase_index, pull.step_index, pull.identity, pull.dispositions + ) + }) + .collect(); manager.delete(job_id).expect("delete completed job"); - completed.expect("expected a completed solution") + Run { + plan, + telemetry, + consumed_pulls, + } } #[test] @@ -54,15 +94,26 @@ fn fixed_work_trajectory_is_identical_across_thread_counts() { .into_iter() .map(|move_threads| solve(&MANAGER, move_threads)) .collect(); - let (baseline, baseline_telemetry) = &runs[0]; + let Run { + plan: baseline, + telemetry: baseline_telemetry, + consumed_pulls: baseline_pulls, + } = &runs[0]; let baseline_assignment: Vec<_> = baseline .tasks .iter() .map(|task| task.resource_idx) .collect(); assert!(baseline_telemetry.moves_applied > 0); + assert!(!baseline_pulls.is_empty()); - for (solution, telemetry) in &runs[1..] { + for Run { + plan: solution, + telemetry, + consumed_pulls, + } in &runs[1..] + { + assert_eq!(consumed_pulls, baseline_pulls); let assignment: Vec<_> = solution .tasks .iter() @@ -84,7 +135,7 @@ fn sequential_resolution_reports_no_speculation() { static MANAGER: SolverManager = SolverManager::new(); for move_threads in [0, 1] { - let (_, telemetry) = solve(&MANAGER, move_threads); - assert_eq!(telemetry.moves_speculative, 0); + let run = solve(&MANAGER, move_threads); + assert_eq!(run.telemetry.moves_speculative, 0); } } diff --git a/crates/solverforge/tests/parallel_local_search/domain/plan.rs b/crates/solverforge/tests/parallel_local_search/domain/plan.rs index 2c66c43c..7cfdc56d 100644 --- a/crates/solverforge/tests/parallel_local_search/domain/plan.rs +++ b/crates/solverforge/tests/parallel_local_search/domain/plan.rs @@ -39,6 +39,9 @@ fn define_constraints() -> impl ConstraintSet { const PARALLEL_SOLVER_TOML: &str = r#" random_seed = 7 +[candidate_trace] +max_entries = 1_000_000 + [[phases]] type = "construction_heuristic" construction_heuristic_type = "first_fit" From 910ecb3e988811fb304ee6eccf128fca0007c21f Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Sun, 25 Oct 2026 23:03:29 +0100 Subject: [PATCH 30/30] docs: state the evaluator ceiling, the sequential Auto, and the host-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. --- README.md | 5 +++-- crates/solverforge-bridge/WIREFRAME.md | 4 +++- crates/solverforge-config/WIREFRAME.md | 4 ++-- crates/solverforge-config/src/solver_config.rs | 5 +++-- crates/solverforge-solver/WIREFRAME.md | 3 ++- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3f5be0c2..af76385b 100644 --- a/README.md +++ b/README.md @@ -745,8 +745,9 @@ Local search can evaluate candidates on several cores within one solve: `move_thread_count = { count = 4 }` in `solver.toml` (Python configs through solverforge-py use `{"move_thread_count": {"count": 4}}`) runs each step's trials on up to four evaluators while one coordinator keeps the same -acceptance order. Results are reproducible at fixed work, and the default -(`auto`) currently stays sequential. +acceptance order. The count is bounded by the solve's Rayon threads and by 32, +the size of one evaluation window. Results are reproducible at fixed work, and +the default (`auto`) currently stays sequential. Absolute throughput depends on the model, constraints, neighborhood, hardware, and termination policy. Use emitted generated/evaluated throughput for one run diff --git a/crates/solverforge-bridge/WIREFRAME.md b/crates/solverforge-bridge/WIREFRAME.md index 8fc7bf13..7e01a631 100644 --- a/crates/solverforge-bridge/WIREFRAME.md +++ b/crates/solverforge-bridge/WIREFRAME.md @@ -225,7 +225,9 @@ no phase builder, selector registration, or alternate construction path. `constraints` builds a constraint set for a given solution; the runtime calls it for the working solution and for every evaluator replica, so `move_thread_count` evaluates dynamic models in parallel. Bindings rebuild -their host constraint set from the solution they receive. +their host constraint set from the solution they receive. Replicas score on +Rayon worker threads, so a binding whose host runtime has a global lock must +release it on the calling thread for the duration of the solve. The optional qualified provenance changes only the candidate-trace header; it does not select another runner. diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 311c42d8..b8175822 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -716,7 +716,7 @@ Derives: `Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize`. | Variant | Note | |---------|------| -| `Auto` | **Default.** Auto-detect thread count | +| `Auto` | **Default.** Sequential until benchmarks set an automatic policy | | `None` | Single-threaded | | `Count(usize)` | Explicit count | @@ -731,7 +731,7 @@ sizes partitioned search separately. | `None` | sequential | | `Auto` | sequential | | `Count(0)`, `Count(1)` | sequential | -| `Count(n >= 2)` | `min(n, rayon::current_num_threads())` evaluators: the main director plus that many minus one replicas | +| `Count(n >= 2)` | `min(n, rayon::current_num_threads(), 32)` evaluators: the main director plus that many minus one replicas; 32 is one full evaluation window | ### `PhaseConfig` diff --git a/crates/solverforge-config/src/solver_config.rs b/crates/solverforge-config/src/solver_config.rs index 59ee43c2..61731116 100644 --- a/crates/solverforge-config/src/solver_config.rs +++ b/crates/solverforge-config/src/solver_config.rs @@ -192,14 +192,15 @@ pub enum EnvironmentMode { #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum MoveThreadCount { - // Automatically determine thread count. + // Sequential until benchmarks set an automatic policy. #[default] Auto, // No parallel move evaluation. None, - // Specific number of threads. + // Evaluator count, main director included; bounded by the solve's + // Rayon threads and by one 32-candidate evaluation window. Count(usize), } diff --git a/crates/solverforge-solver/WIREFRAME.md b/crates/solverforge-solver/WIREFRAME.md index f3eaada3..a0060429 100644 --- a/crates/solverforge-solver/WIREFRAME.md +++ b/crates/solverforge-solver/WIREFRAME.md @@ -79,6 +79,7 @@ src/ ├── stats.rs — Statistics, telemetry, and candidate-trace re-export root ├── stats/ — Solver/phase stats, telemetry payloads, candidate traces, and qualified provenance ├── test_utils.rs — TestSolution, TestDirector, NQueens helpers +├── test_utils/alloc_counter.rs — Per-thread counting allocator for allocation pins (tests only) ├── test_utils_tests.rs — Tests │ ├── heuristic/ @@ -283,7 +284,7 @@ src/ │ │ ├── parallel/config.rs — move_thread_count resolution to an evaluator count │ │ ├── parallel/pool.rs — EvaluatorPool: evaluator replicas, sync, and post-commit advance │ │ ├── parallel/window.rs — Window evaluation across the main director and replicas -│ │ ├── parallel/tests.rs — Tests +│ │ ├── parallel/tests/ — Pool ceiling, window sizing, trajectory parity, and allocation-free window tests │ │ └── acceptor/ │ │ ├── mod.rs — Acceptor module declarations and re-exports │ │ ├── traits.rs — Acceptor trait