diff --git a/AGENTS.md b/AGENTS.md index 1eab4b9ca..91f29a106 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,12 @@ 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 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`. @@ -276,6 +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 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/README.md b/README.md index f176bbee7..af76385ba 100644 --- a/README.md +++ b/README.md @@ -741,6 +741,14 @@ 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. 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 and the controlled paired benchmark gate below for regression decisions. diff --git a/assets/solverforge-architecture.svg b/assets/solverforge-architecture.svg index 4e97e178e..3ded0f324 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/crates/solverforge-bridge/WIREFRAME.md b/crates/solverforge-bridge/WIREFRAME.md index 229095bb3..7e01a6317 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,10 +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 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 @@ -185,7 +200,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 +222,12 @@ 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. 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-bridge/src/lib.rs b/crates/solverforge-bridge/src/lib.rs index a2c3c39a6..0578b3b0a 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/runner.rs b/crates/solverforge-bridge/src/runner.rs index d04a18da5..9539af20a 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 5c94dd800..cea2352e4 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,9 +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 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, @@ -40,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) } @@ -57,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 { @@ -135,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!( @@ -184,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), ) } } @@ -213,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), ) } } @@ -226,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) } } @@ -236,7 +166,7 @@ impl Score for DynamicScore { } fn zero() -> Self { - Self::zero_for_family(active_score_family()) + Self::ZERO } fn levels_count() -> usize { @@ -253,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, ) } @@ -287,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 { @@ -310,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 fd2dcee18..d6989396d 100644 --- a/crates/solverforge-bridge/src/score_tests.rs +++ b/crates/solverforge-bridge/src/score_tests.rs @@ -1,10 +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()); @@ -17,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!( + display(DynamicScoreFamily::Soft, DynamicScore::soft(3)), + "3" + ); + assert_eq!( + display(DynamicScoreFamily::HardSoft, DynamicScore::hard_soft(-1, 3)), + "-1hard/3soft" + ); assert_eq!( - DynamicScore::hard_soft_decimal(-100_000, 150_500).to_string(), + 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(), @@ -51,7 +83,38 @@ 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)); } + +#[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); + assert_eq!(hash_of(declared), hash_of(DynamicScore::ZERO)); + assert!(DynamicScore::of(0, 0, 0) > DynamicScore::of(-1, 100, 100)); +} + +#[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"); + }); +} diff --git a/crates/solverforge-config/WIREFRAME.md b/crates/solverforge-config/WIREFRAME.md index 44af1d6ad..b81758220 100644 --- a/crates/solverforge-config/WIREFRAME.md +++ b/crates/solverforge-config/WIREFRAME.md @@ -716,10 +716,23 @@ 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 | +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(), 32)` evaluators: the main director plus that many minus one replicas; 32 is one full evaluation window | + ### `PhaseConfig` Derives: `Debug, Clone, Deserialize, Serialize`. Tagged `#[serde(tag = "type", rename_all = "snake_case")]`. diff --git a/crates/solverforge-config/src/solver_config.rs b/crates/solverforge-config/src/solver_config.rs index 59ee43c28..617311161 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-config/src/tests/parsing.rs b/crates/solverforge-config/src/tests/parsing.rs index 6d97c4987..764d81e58 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-core/WIREFRAME.md b/crates/solverforge-core/WIREFRAME.md index f9a53a2ee..c0d5b5c16 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 fc470b9c6..d96914259 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. diff --git a/crates/solverforge-macros/WIREFRAME.md b/crates/solverforge-macros/WIREFRAME.md index 9a86c9f94..091dda3ae 100644 --- a/crates/solverforge-macros/WIREFRAME.md +++ b/crates/solverforge-macros/WIREFRAME.md @@ -247,14 +247,14 @@ 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()` - `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 @@ -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 50041e2b0..da2f61bcc 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/runtime/solve.rs b/crates/solverforge-macros/src/planning_solution/runtime/solve.rs index a52406558..afcceef6d 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/shadow.rs b/crates/solverforge-macros/src/planning_solution/shadow.rs index 729dccd62..cd1099c58 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 3d2b8c619..a221553ea 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-macros/src/planning_solution_tests.rs b/crates/solverforge-macros/src/planning_solution_tests.rs index 413f86402..ea83a30bf 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-scoring/WIREFRAME.md b/crates/solverforge-scoring/WIREFRAME.md index 8ddc44766..a8183dcdb 100644 --- a/crates/solverforge-scoring/WIREFRAME.md +++ b/crates/solverforge-scoring/WIREFRAME.md @@ -212,7 +212,9 @@ 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/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/ @@ -222,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/ @@ -333,7 +336,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,6 +379,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, sharing the director's borrowed descriptor; default: `None` | ### `DirectorScoreState` @@ -421,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 | @@ -502,7 +514,7 @@ 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()`, `simple()` (convenience for `ScoreDirector`), `simple_zero()` (test helper with empty descriptor), @@ -515,8 +527,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_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 fa81f5753..141fcb51a 100644 --- a/crates/solverforge-scoring/src/director/score_director.rs +++ b/crates/solverforge-scoring/src/director/score_director.rs @@ -5,6 +5,9 @@ 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/adapters.rs b/crates/solverforge-scoring/src/director/score_director/adapters.rs index cd39bd8c9..380fdc0ba 100644 --- a/crates/solverforge-scoring/src/director/score_director/adapters.rs +++ b/crates/solverforge-scoring/src/director/score_director/adapters.rs @@ -1,30 +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.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() @@ -43,7 +50,7 @@ where } fn solution_descriptor(&self) -> &SolutionDescriptor { - &self.solution_descriptor + self.solution_descriptor.solution_descriptor() } fn clone_working_solution(&self) -> S { @@ -66,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) @@ -91,4 +102,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/descriptor.rs b/crates/solverforge-scoring/src/director/score_director/descriptor.rs new file mode 100644 index 000000000..f92971191 --- /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 175d17571..5f0642dc4 100644 --- a/crates/solverforge-scoring/src/director/score_director/incremental.rs +++ b/crates/solverforge-scoring/src/director/score_director/incremental.rs @@ -3,6 +3,8 @@ use std::marker::PhantomData; 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. @@ -15,6 +17,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 +66,18 @@ 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, + list_shadow_rows: ListShadowRows, + pub(super) solution_descriptor: Desc, /* Entity counter function. Returns the number of entities for the given descriptor index. @@ -103,7 +110,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 @@ -113,13 +128,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 { @@ -127,12 +143,34 @@ where constraints, cached_score: S::Score::zero(), initialized: false, + list_shadow_rows: ListShadowRows::default(), solution_descriptor, entity_counter, _phantom: PhantomData, } } + /* 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 (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, + descriptor, + self.entity_counter, + ); + replica.calculate_score_impl(); + Some(replica) + } + /* ========================================================================= Private implementation methods (shared between inherent and trait impl) ========================================================================= @@ -165,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( @@ -176,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(); } @@ -212,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 000000000..e21d5cd11 --- /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-scoring/src/director/tests/list_shadows.rs b/crates/solverforge-scoring/src/director/tests/list_shadows.rs new file mode 100644 index 000000000..d2ddc1426 --- /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 4f0877473..24cec46af 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; diff --git a/crates/solverforge-scoring/src/director/tests/score_director.rs b/crates/solverforge-scoring/src/director/tests/score_director.rs index fbec04179..9020c3ad8 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}; @@ -307,3 +307,86 @@ 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 test_descriptor() -> SolutionDescriptor { + SolutionDescriptor::new("TestSolution", TypeId::of::()) +} + +fn unassigned_constraints( + _solution: &TestSolution, +) -> (impl IncrementalConstraint,) { + (make_unassigned_constraint(),) +} + +#[test] +fn fork_replica_requires_a_replica_descriptor() { + 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 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 + .fork_replica() + .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); + 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 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 + .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 1bc9de4bd..ed55a725c 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 + } } diff --git a/crates/solverforge-scoring/src/lib.rs b/crates/solverforge-scoring/src/lib.rs index 94b596bff..12a9e2480 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 889de513d..41a0bace0 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/ @@ -266,13 +267,24 @@ 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 — 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 │ │ ├── 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/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/ — Pool ceiling, window sizing, trajectory parity, and allocation-free window tests │ │ └── acceptor/ │ │ ├── mod.rs — Acceptor module declarations and re-exports │ │ ├── traits.rs — Acceptor trait @@ -420,7 +432,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 | @@ -1245,6 +1257,42 @@ 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 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 +`Director::fork_replica`, again whenever the working solution was written +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: + +- 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 + 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 + 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. +- 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 @@ -1426,7 +1474,7 @@ Public fields: `inphase_step_count_limit`, `inphase_move_count_limit`, `inphase_ ### `PhaseScope<'t, 'a, S, D, BestCb = ()>` -Borrows `&mut SolverScope`. Tracks per-phase state: phase_index, starting_score, step_count, PhaseStats. Public committed mutation delegates to `mutate(...)` on the parent solver scope; speculative candidate evaluation is handled by phase evaluation helpers using typed move undo. +Borrows `&mut SolverScope`. Tracks per-phase state: phase_index, starting_score, step_count; its `PhaseStats` record lives in the solver's `SolverStats` for the scope's lifetime. Public committed mutation delegates to `mutate(...)` on the parent solver scope; speculative candidate evaluation is handled by phase evaluation helpers using typed move undo. ### `StepScope<'t, 'a, 'b, S, D, BestCb = ()>` @@ -1596,14 +1644,34 @@ Aggregate and per-phase metrics: step count, moves generated, moves evaluated, moves accepted, moves applied, score calculations, elapsed time, generation time, evaluation time, acceptance rate, selector-level telemetry, and exact `Throughput { count, elapsed }` views for generated/evaluated work. + +`SolverStats` records every counter exactly once, into the running phase's +`PhaseStats` (or, between phases, into solve-level work such as the initial +score calculation). `begin_phase(phase_type)` opens a phase record and +`end_phase()` freezes it; `PhaseScope` opens its record on creation and closes +it on drop, and `PhaseScope::stats()` / `stats_mut()` borrow that record. Run +counters are read through methods (`step_count()`, `moves_evaluated()`, +`moves_speculative()`, `score_calculations()`, ...) that sum the finished +phases, solve-level work, and the running phase. `phase()` / `phase_mut()` / +`phase_elapsed()` expose the running phase. `SolverStats` also owns the +pause-aware solve clock: `start()`, `pause()`, `resume()`, `elapsed()`, +`active_elapsed()`, `started_at()`, and `is_paused()`; `SolverScope::elapsed()` +and `PhaseScope::elapsed()` read it. 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 -`PhaseTelemetry` snapshot identifying the active phase and its local elapsed, -step, move, score-calculation, generation-time, and evaluation-time counters; +`SolverTelemetry` snapshots expose the same counters plus `phases`, one +`PhaseTelemetry` per phase started so far in execution order (the last entry of +a progress snapshot is the running phase), each with its `phase_index`, +`phase_type`, solver-active elapsed, and step, move (including +`moves_speculative`), score-calculation, generation-time, and evaluation-time +counters. Phase counters sum to the run counters, apart from score +calculations made outside any phase. Run snapshots also carry not-doable, acceptor-rejected, forager-ignored, hard-improving/neutral/worse, conflict repair provider/filter/exposure counters, `construction_slots_assigned`, @@ -1647,7 +1715,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 @@ -1731,11 +1800,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/benches/selector_cursor_gate/construction_cases.rs b/crates/solverforge-solver/benches/selector_cursor_gate/construction_cases.rs index 5ffdbc7d3..5cb3037e3 100644 --- a/crates/solverforge-solver/benches/selector_cursor_gate/construction_cases.rs +++ b/crates/solverforge-solver/benches/selector_cursor_gate/construction_cases.rs @@ -98,7 +98,7 @@ fn construction_first_fit() { let mut scope = SolverScope::new(unassigned_director(128)); scope.start_solving(); phase.solve(&mut scope); - count += scope.stats().moves_generated as usize; + count += scope.stats().moves_generated() as usize; for vehicle in &scope.working_solution().vehicles { mix(&mut hash, vehicle.visits[0]); } 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 f8ad0fe18..bb26beb49 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 317de53af..6ca02c1d9 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 4e71aa3c5..d32e350a9 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 b4987e1a2..0614c83a9 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 b83a0900a..bec90c9b8 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 36442cea4..b86ccf879 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 b4f903ab4..70c8bef56 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 d2e265a37..721b80458 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 77c1742dc..df8819d1a 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 533c6efb6..9e9cea287 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 173f00ec2..86ff275f7 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 a0e02ebb4..623ee35f0 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 59c790fbc..8cee750b5 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 b4df52b64..72da2cb44 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 a73c606e6..c37e00b7c 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 d5a82b430..7ca19b64e 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 144a33756..f40961dd5 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 44cf84074..96ca81444 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 538001382..f6ef459dc 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 f3b3383eb..d14d0cd4c 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 2e725d692..35f99460b 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 64a9feb3a..dadf3e13d 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 6f6bffca3..9bdaccad7 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 7910cb29e..5dca3ec19 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 2af6d072b..46bacaca9 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 f75a7d211..b4dfc5cf8 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 1eccc2501..17913944f 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 0bf1858ab..ea58e4f57 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 1361fbfa7..30f10270a 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 d434bf633..70101788e 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 608b8383c..35e6bd8b6 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 6e3c07fca..04e22773b 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 ef0773ab3..1394123a7 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 9cb6b5d98..0f81af952 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 57d16b0d5..c217ae7fb 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 000000000..36d632473 --- /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 0efb3093e..21e9a75e5 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 8ef36357f..915cfe1df 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 7f44b3f3f..e9080be4b 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 4aa0f1993..89026ad97 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 43b384919..fcde6cfca 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 d770b5a1f..8b58e5c4b 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 a666af324..fd4a8e161 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) } @@ -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)) => { @@ -278,7 +220,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), @@ -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/heuristic/selector/scalar_neighborhood/move.rs b/crates/solverforge-solver/src/heuristic/selector/scalar_neighborhood/move.rs index acdc20980..401b96b50 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 a7a75bbe6..032870447 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/lifecycle_solutions.rs b/crates/solverforge-solver/src/manager/mod_tests_integration/lifecycle_solutions.rs index 8589fb3c8..ed623bb01 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/manager/mod_tests_integration/prompt_support.rs b/crates/solverforge-solver/src/manager/mod_tests_integration/prompt_support.rs index ffb262242..253083849 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/manager/phase_factory/k_opt.rs b/crates/solverforge-solver/src/manager/phase_factory/k_opt.rs index 7a7abd01b..af77ec477 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/manager/phase_factory/list_clarke_wright/tests/interruption.rs b/crates/solverforge-solver/src/manager/phase_factory/list_clarke_wright/tests/interruption.rs index 4caae62c0..44586c299 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_clarke_wright/tests/interruption.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_clarke_wright/tests/interruption.rs @@ -90,8 +90,8 @@ fn interrupted_clarke_wright_savings_preserves_committed_complete_assignment() { let mut assigned = solver_scope.working_solution().routes[0].visits.clone(); assigned.sort_unstable(); assert_eq!(assigned, (1..=customer_count).collect::>()); - assert_eq!(solver_scope.stats().moves_accepted, customer_count as u64); - assert_eq!(solver_scope.stats().moves_applied, customer_count as u64); + assert_eq!(solver_scope.stats().moves_accepted(), customer_count as u64); + assert_eq!(solver_scope.stats().moves_applied(), customer_count as u64); assert_eq!( solver_scope.terminal_reason(), crate::manager::SolverTerminalReason::TerminatedByConfig @@ -138,14 +138,14 @@ fn interrupted_clarke_wright_merge_publishes_buffered_merges() { let mut assigned = solver_scope.working_solution().routes[0].visits.clone(); assigned.sort_unstable(); assert_eq!(assigned, (1..=customer_count).collect::>()); - assert!(solver_scope.stats().moves_accepted > 0); + assert!(solver_scope.stats().moves_accepted() > 0); // The buffered merges are a strictly better complete assignment than the // balanced seed, so an interrupted phase publishes them rather than // dropping them and leaving the seed as the best solution. - assert!(solver_scope.stats().moves_applied > customer_count as u64); + assert!(solver_scope.stats().moves_applied() > customer_count as u64); assert_eq!( - solver_scope.stats().moves_applied, - solver_scope.stats().moves_accepted + solver_scope.stats().moves_applied(), + solver_scope.stats().moves_accepted() ); assert_eq!( solver_scope.terminal_reason(), @@ -309,8 +309,8 @@ fn termination_before_complete_assignment_keeps_the_working_list_empty() { phase.solve(&mut solver_scope); assert!(solver_scope.working_solution().routes[0].visits.is_empty()); - assert_eq!(solver_scope.stats().moves_accepted, 0); - assert_eq!(solver_scope.stats().moves_applied, 0); + assert_eq!(solver_scope.stats().moves_accepted(), 0); + assert_eq!(solver_scope.stats().moves_applied(), 0); assert_eq!( solver_scope.terminal_reason(), crate::manager::SolverTerminalReason::TerminatedByConfig @@ -363,8 +363,8 @@ fn interrupted_clarke_wright_completion_discards_local_assignments() { assert!(completed.is_none()); assert!(solver_scope.working_solution().routes[0].visits.is_empty()); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 0); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 0); assert_eq!( solver_scope.terminal_reason(), crate::manager::SolverTerminalReason::TerminatedByConfig diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_construction/cheapest/tests.rs b/crates/solverforge-solver/src/manager/phase_factory/list_construction/cheapest/tests.rs index af4f695f4..33cb8f0e7 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_construction/cheapest/tests.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_construction/cheapest/tests.rs @@ -238,7 +238,9 @@ fn public_cheapest_streams_progress_from_the_first_candidate() { if progress.kind == SolverProgressKind::Progress { let phase = progress .telemetry - .phase + .phases + .last() + .cloned() .expect("construction progress should include phase telemetry"); captured .lock() @@ -265,8 +267,8 @@ fn public_cheapest_streams_progress_from_the_first_candidate() { assert!(first.moves_generated > 0); assert!(first.moves_evaluated > 0); assert_eq!(*current_score, Some(HardSoftScore::ZERO)); - assert!(scope.stats().moves_accepted > 0); - assert!(scope.stats().moves_applied > 0); + assert!(scope.stats().moves_accepted() > 0); + assert!(scope.stats().moves_applied() > 0); } #[test] diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_construction/round_robin/tests.rs b/crates/solverforge-solver/src/manager/phase_factory/list_construction/round_robin/tests.rs index d445516f6..8c63775a4 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_construction/round_robin/tests.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_construction/round_robin/tests.rs @@ -112,7 +112,9 @@ fn public_round_robin_streams_progress_from_the_first_candidate() { if progress.kind == SolverProgressKind::Progress { let phase = progress .telemetry - .phase + .phases + .last() + .cloned() .expect("construction progress should include phase telemetry"); assert_eq!(phase.phase_type, "Round-Robin List Construction"); assert!(phase.moves_generated > 0); @@ -129,6 +131,6 @@ fn public_round_robin_streams_progress_from_the_first_candidate() { phase.solve(&mut scope); assert!(progress_events.load(Ordering::SeqCst) > 0); - assert!(scope.stats().moves_accepted > 0); - assert!(scope.stats().moves_applied > 0); + assert!(scope.stats().moves_accepted() > 0); + assert!(scope.stats().moves_applied() > 0); } diff --git a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs index 1e2f23093..521f3b292 100644 --- a/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs +++ b/crates/solverforge-solver/src/manager/phase_factory/list_k_opt.rs @@ -478,8 +478,8 @@ mod tests { solver_scope.working_solution().routes[0].visits, original_route ); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 0); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 0); assert_eq!( solver_scope.terminal_reason(), crate::manager::SolverTerminalReason::TerminatedByConfig diff --git a/crates/solverforge-solver/src/model_support.rs b/crates/solverforge-solver/src/model_support.rs index 773396512..55cb9d1a5 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 + } } diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests.rs b/crates/solverforge-solver/src/phase/construction/phase/tests.rs index 53d462413..2a756da59 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/interruption.rs b/crates/solverforge-solver/src/phase/construction/phase/tests/interruption.rs index 7bcd5096e..260ea6b55 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests/interruption.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests/interruption.rs @@ -12,10 +12,10 @@ fn time_limit_after_first_fit_selection_commits_before_termination() { phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.terminal_reason(), SolverTerminalReason::TerminatedByConfig @@ -41,10 +41,10 @@ fn time_limit_after_first_feasible_selection_commits_before_termination() { phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.terminal_reason(), SolverTerminalReason::TerminatedByConfig @@ -70,10 +70,10 @@ fn assert_direct_limit_commits_and_terminates( phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.terminal_reason(), SolverTerminalReason::TerminatedByConfig @@ -120,10 +120,10 @@ fn shared_move_budget_commits_selection_before_child_termination() { phase.solve(&mut child_scope); assert_eq!(child_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(child_scope.stats().moves_generated, 1); - assert_eq!(child_scope.stats().moves_evaluated, 1); - assert_eq!(child_scope.stats().moves_accepted, 1); - assert_eq!(child_scope.stats().moves_applied, 1); + assert_eq!(child_scope.stats().moves_generated(), 1); + assert_eq!(child_scope.stats().moves_evaluated(), 1); + assert_eq!(child_scope.stats().moves_accepted(), 1); + assert_eq!(child_scope.stats().moves_applied(), 1); assert_eq!( child_scope.terminal_reason(), SolverTerminalReason::TerminatedByConfig @@ -146,8 +146,8 @@ fn cancellation_after_decisive_evaluation_keeps_the_committed_selection() { phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!(solver_scope.terminal_reason(), SolverTerminalReason::Cancelled); }); diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests/lifecycle.rs b/crates/solverforge-solver/src/phase/construction/phase/tests/lifecycle.rs index c71793e9b..53f856238 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests/lifecycle.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests/lifecycle.rs @@ -36,7 +36,9 @@ fn construction_reports_progress_for_a_single_first_fit_candidate() { if progress.kind == crate::scope::SolverProgressKind::Progress { let phase = progress .telemetry - .phase + .phases + .last() + .cloned() .expect("construction progress should include phase telemetry"); assert_eq!(phase.phase_type, "Construction Heuristic"); assert_eq!(phase.moves_generated, 1); @@ -101,7 +103,7 @@ fn test_construction_empty_solution() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 0); + assert_eq!(solver_scope.stats().moves_evaluated(), 0); } #[test] diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests/selection.rs b/crates/solverforge-solver/src/phase/construction/phase/tests/selection.rs index 2716be3e2..b2252ad90 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests/selection.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests/selection.rs @@ -19,7 +19,7 @@ fn test_construction_first_fit() { } assert!(solver_scope.best_solution().is_some()); - assert!(solver_scope.stats().moves_evaluated > 0); + assert!(solver_scope.stats().moves_evaluated() > 0); } #[test] @@ -42,7 +42,7 @@ fn test_construction_best_fit() { assert!(solver_scope.best_solution().is_some()); assert!(solver_scope.best_score().is_some()); - assert_eq!(solver_scope.stats().moves_evaluated, 16); + assert_eq!(solver_scope.stats().moves_evaluated(), 16); } #[test] @@ -61,8 +61,8 @@ fn best_fit_keeps_current_when_every_assignment_is_worse() { solver_scope.current_score().copied(), Some(SoftScore::of(0)) ); - assert_eq!(solver_scope.stats().moves_accepted, 0); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 0); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -81,7 +81,7 @@ fn best_fit_assigns_when_candidate_is_strictly_better_than_none() { solver_scope.current_score().copied(), Some(SoftScore::of(7)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); } #[test] @@ -100,8 +100,8 @@ fn first_fit_optional_construction_keeps_current_when_baseline_is_not_beaten() { solver_scope.current_score().copied(), Some(SoftScore::of(0)) ); - assert_eq!(solver_scope.stats().moves_accepted, 0); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 0); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -123,8 +123,8 @@ fn first_fit_forced_construction_assigns_first_doable_candidate_even_when_worse( solver_scope.current_score().copied(), Some(SoftScore::of(-5)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -146,8 +146,8 @@ fn best_fit_forced_construction_assigns_best_candidate_even_when_worse() { solver_scope.current_score().copied(), Some(SoftScore::of(-1)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -166,8 +166,8 @@ fn first_fit_optional_construction_skips_worse_candidate_and_takes_later_improve solver_scope.current_score().copied(), Some(SoftScore::of(7)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -186,8 +186,8 @@ fn first_fit_optional_construction_takes_first_improving_candidate() { solver_scope.current_score().copied(), Some(SoftScore::of(7)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().step_count(), 1); } #[test] @@ -202,8 +202,8 @@ fn first_fit_required_construction_still_selects_first_doable_candidate() { phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, Some(3)); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().step_count, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().step_count(), 1); } #[derive(Debug)] @@ -292,7 +292,7 @@ fn best_fit_prefers_equal_score_candidate_over_keep_current() { solver_scope.current_score().copied(), Some(SoftScore::of(0)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); } #[test] @@ -325,7 +325,7 @@ fn best_fit_progresses_across_equal_score_plateau() { solver_scope.current_score().copied(), Some(SoftScore::of(5)) ); - assert_eq!(solver_scope.stats().moves_accepted, 2); + assert_eq!(solver_scope.stats().moves_accepted(), 2); } #[test] @@ -340,7 +340,7 @@ fn first_feasible_keeps_current_when_baseline_is_already_feasible() { phase.solve(&mut solver_scope); assert_eq!(solver_scope.working_solution().entities[0].value, None); - assert_eq!(solver_scope.stats().moves_accepted, 0); + assert_eq!(solver_scope.stats().moves_accepted(), 0); } #[test] @@ -364,7 +364,7 @@ fn first_feasible_selects_first_feasible_move_when_baseline_is_infeasible() { solver_scope.current_score().copied(), Some(SoftScore::of(1)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); } #[test] @@ -388,5 +388,5 @@ fn first_feasible_prefers_equal_score_candidate_over_infeasible_baseline() { solver_scope.current_score().copied(), Some(SoftScore::of(-1)) ); - assert_eq!(solver_scope.stats().moves_accepted, 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); } diff --git a/crates/solverforge-solver/src/phase/construction/phase/tests/streaming.rs b/crates/solverforge-solver/src/phase/construction/phase/tests/streaming.rs index 54db11266..a0228bfa9 100644 --- a/crates/solverforge-solver/src/phase/construction/phase/tests/streaming.rs +++ b/crates/solverforge-solver/src/phase/construction/phase/tests/streaming.rs @@ -118,11 +118,11 @@ fn first_fit_stops_after_the_decisive_candidate() { assert_eq!(pulls.load(Ordering::SeqCst), 1); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); - assert_eq!(solver_scope.stats().score_calculations, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); + assert_eq!(solver_scope.stats().score_calculations(), 1); } #[test] @@ -147,9 +147,9 @@ fn first_feasible_stops_after_the_decisive_candidate() { assert_eq!(pulls.load(Ordering::SeqCst), 1); assert_eq!(solver_scope.working_solution().entities[0].value, Some(0)); - assert_eq!(solver_scope.stats().moves_generated, 1); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_accepted, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); - assert_eq!(solver_scope.stats().score_calculations, 2); + assert_eq!(solver_scope.stats().moves_generated(), 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_accepted(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); + assert_eq!(solver_scope.stats().score_calculations(), 2); } 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 9662e0c12..a0e861f4d 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 3f5923003..9540fd127 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/construction/telemetry.rs b/crates/solverforge-solver/src/phase/construction/telemetry.rs index 4cbfeee09..5d3435a02 100644 --- a/crates/solverforge-solver/src/phase/construction/telemetry.rs +++ b/crates/solverforge-solver/src/phase/construction/telemetry.rs @@ -275,7 +275,9 @@ mod tests { .push( progress .telemetry - .phase + .phases + .last() + .cloned() .expect("construction progress should include phase telemetry") .moves_evaluated, ); @@ -325,7 +327,7 @@ mod tests { pending_move_telemetry.record_committed(&mut phase_scope); assert_eq!(phase_scope.stats().moves_applied, 2); drop(phase_scope); - assert_eq!(solver_scope.stats().moves_accepted, 2); - assert_eq!(solver_scope.stats().moves_applied, 2); + assert_eq!(solver_scope.stats().moves_accepted(), 2); + assert_eq!(solver_scope.stats().moves_applied(), 2); } } 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 965fca601..ac12f8201 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}; @@ -43,10 +44,10 @@ fn vnd_accepts_required_repair_that_improves_later_hard_level() { Some(BendableScore::of([0, -5], [-100])) ); let stats = solver_scope.stats(); - assert_eq!(stats.moves_accepted, 1); - assert_eq!(stats.moves_acceptor_rejected, 1); - assert_eq!(stats.moves_hard_improving, 1); - assert_eq!(stats.moves_hard_neutral, 1); + assert_eq!(stats.moves_accepted(), 1); + assert_eq!(stats.moves_acceptor_rejected(), 1); + assert_eq!(stats.moves_hard_improving(), 1); + assert_eq!(stats.moves_hard_neutral(), 1); } #[derive(Clone, Debug)] @@ -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/evaluation.rs b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs index 9324c79bf..03f463daa 100644 --- a/crates/solverforge-solver/src/phase/localsearch/evaluation.rs +++ b/crates/solverforge-solver/src/phase/localsearch/evaluation.rs @@ -1,11 +1,11 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use solverforge_core::domain::PlanningSolution; 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}; @@ -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>, @@ -24,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, @@ -54,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); @@ -118,6 +126,130 @@ where CandidateEvaluation::Scored(move_score) } +/* 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_recycling(director, journal_pool); + let move_score = director.calculate_score(); + mov.undo_move_recycling(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_elapsed(step_scope, selector_index, elapsed); + 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; + } + TrialOutcome::Scored(move_score) => move_score, + }; + + 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_elapsed(step_scope, selector_index, elapsed); + 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_elapsed(step_scope, selector_index, elapsed); + 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) +} + #[inline] pub(crate) fn record_evaluated_move( step_scope: &mut StepScope<'_, '_, '_, S, D, ProgressCb>, @@ -127,14 +259,28 @@ pub(crate) fn record_evaluated_move( 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, +) where + S: PlanningSolution, + D: Director, + ProgressCb: ProgressCallback, { 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/mod.rs b/crates/solverforge-solver/src/phase/localsearch/mod.rs index 8731fa3fa..e798f0ef2 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; @@ -24,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/budget.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/budget.rs new file mode 100644 index 000000000..fec43c395 --- /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/config.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs new file mode 100644 index 000000000..c7e13368d --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/config.rs @@ -0,0 +1,13 @@ +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, and the +evaluator pool caps it again at one full window of candidates. +*/ +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 new file mode 100644 index 000000000..ebf1e6eda --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/mod.rs @@ -0,0 +1,19 @@ +/* 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 config; +mod pool; +mod window; + +#[cfg(test)] +mod tests; + +pub(crate) use budget::remaining_evaluation_budget; +pub(crate) use config::resolve_evaluator_count; +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 new file mode 100644 index 000000000..dcee4aaaf --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/pool.rs @@ -0,0 +1,197 @@ +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; +use crate::phase::localsearch::evaluation::TrialOutcome; +use crate::phase::localsearch::phase::WindowSlot; +use crate::scope::{ProgressCallback, SolverScope}; + +/* 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; + +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 +`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, +{ + evaluators: usize, + replicas: Vec>, + main_outputs: TrialOutputs, + // Window buffers lent to each step, so steps reuse their capacity. + window_slots: Vec, + window_results: WindowResults, + // 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)>, +} + +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; it is + // capped at the window ceiling. + pub(crate) fn new(requested: usize) -> Self { + Self { + evaluators: requested.clamp(1, MAX_PARALLEL_EVALUATION_WINDOW), + replicas: Vec::new(), + main_outputs: Vec::new(), + window_slots: Vec::new(), + window_results: Vec::new(), + last_step_consumed: 0, + step_dispatched: 0, + 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() + } + + pub(crate) fn begin_step(&mut self) { + self.step_dispatched = 0; + } + + /// 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. Only the parallel loop sizes windows. + pub(crate) fn window_capacity(&self, remaining: Option) -> usize { + let window = self + .last_step_consumed + .saturating_sub(self.step_dispatched) + .clamp(self.evaluators, MAX_PARALLEL_EVALUATION_WINDOW); + let remaining = remaining.map_or(usize::MAX, |remaining| { + usize::try_from(remaining).unwrap_or(usize::MAX) + }); + window.min(remaining).max(1) + } + + pub(crate) fn record_window(&mut self, len: usize) { + self.step_dispatched += len; + } + + 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 + 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.evaluators <= 1 || self.synced_generation == Some(generation) { + return; + } + self.replicas.clear(); + 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", + ); + 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/mod.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs new file mode 100644 index 000000000..edc9fe1e6 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/tests/mod.rs @@ -0,0 +1,8 @@ +// Tests for parallel local-search evaluation. + +mod support; + +mod pool; +mod sizing; +mod trajectory; +mod window; 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 000000000..6f84f4b0f --- /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 000000000..f63863b41 --- /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 000000000..3d1ced058 --- /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 000000000..0525e599b --- /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); +} 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 000000000..1a0788811 --- /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/phase/localsearch/parallel/window.rs b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs new file mode 100644 index 000000000..a753b777c --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/parallel/window.rs @@ -0,0 +1,90 @@ +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; +use crate::phase::localsearch::evaluation::{run_trial, TrialOutcome}; + +use super::pool::{EvaluatorPool, TrialOutputs}; + +/* 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, + 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::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, + &mut replica.director, + &mut replica.journal_pool, + &mut replica.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 c1f6f720f..dd90dfdf8 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, }; @@ -18,8 +19,14 @@ use crate::scope::{PhaseScope, ProgressCallback, SolverScope}; use crate::stats::{format_duration, whole_units_per_second, CandidateTracePullToken}; 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; @@ -230,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, @@ -240,6 +249,48 @@ 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, + D: Director, + BestCb: ProgressCallback, + M: Move, + 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, @@ -249,6 +300,7 @@ pub(crate) fn solve_local_search_with_resources( 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(); @@ -278,6 +330,7 @@ pub(crate) fn solve_local_search_with_resources( forager, &mut phase_scope, &mut last_step_score, + evaluator, ) { StepOutcome::Continue | StepOutcome::Restart => continue, StepOutcome::Terminate => break, @@ -344,6 +397,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 6e7223364..8ece37e02 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/candidates.rs @@ -1,28 +1,25 @@ -//! Candidate streaming for one local-search step. +//! Candidate state for one local-search step and the parallel window loop. +use smallvec::SmallVec; 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, 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::evaluation::{ - evaluate_candidate, record_evaluated_move, CandidateEvaluation, +use crate::phase::localsearch::parallel::{ + evaluate_window, remaining_evaluation_budget, EvaluatorPool, MAX_PARALLEL_EVALUATION_WINDOW, }; -use crate::phase::localsearch::{Acceptor, ForagerDecision, LocalSearchForager}; +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::CandidateTracePullToken; -const PROGRESS_POLL_INTERVAL: u64 = 256; -// Candidate iterations between control polls in the step loop. +use super::decision::{ + consume_candidate, discard_window_from, interrupt_window_at, record_generated_candidate, +}; +use super::StepMoveLabelCounts; pub(super) struct CandidateLoopState { pub(super) interrupted: bool, @@ -46,8 +43,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>, @@ -55,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 @@ -69,255 +70,139 @@ 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. - let mut journal_pool: Vec> = Vec::new(); + // + // 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 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; } - 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); + // 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 step_scope.progress_polling_required() - && state.generated_moves.is_multiple_of(PROGRESS_POLL_INTERVAL) - { - step_scope.phase_scope_mut().report_progress_if_due(); + if window.is_empty() { + break; } - 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) = 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; } - 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, + // 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 + .candidate(slot.id) + .expect("window candidate must remain borrowable") + }) + .collect(); + evaluate_window( + &candidates, + step_scope.score_director_mut(), &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); - 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, + evaluators, + &mut results, ); - - 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)); + // The borrowed candidates end here; consumption releases them. + drop(candidates); + + // 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; } - 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)); + 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; } } - } else { - assert!(cursor.release_candidate(candidate_id)); + 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; } + evaluators.record_window(window.len()); } + evaluators.finish_step(state.evaluated_moves - consumed_before); + evaluators.restore_window_buffers(window, results); 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 000000000..0db6b89e6 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/phase/decision.rs @@ -0,0 +1,342 @@ +//! 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_elapsed, 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}; + +pub(super) const PROGRESS_POLL_INTERVAL: u64 = 256; + +/// A generated candidate waiting for its trial result. +pub(in crate::phase::localsearch) 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; + } + }; + // 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.as_deref()); + + record_evaluated_elapsed(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)); + } +} + +/// 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)); + } +} + +pub(super) 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/evaluator.rs b/crates/solverforge-solver/src/phase/localsearch/phase/evaluator.rs new file mode 100644 index 000000000..e000d4cb3 --- /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 000000000..a7d466690 --- /dev/null +++ b/crates/solverforge-solver/src/phase/localsearch/phase/sequential.rs @@ -0,0 +1,280 @@ +//! 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::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; 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.as_deref()); + + 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 2d579ca22..98c9003dc 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/step.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/step.rs @@ -17,7 +17,8 @@ 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 { @@ -27,13 +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, + evaluator: &mut E, ) -> StepOutcome where S: PlanningSolution, @@ -43,8 +45,10 @@ where Fo: LocalSearchForager, D: Director, BestCb: ProgressCallback, + E: StepEvaluator, { let mut step_scope = StepScope::new(phase_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 @@ -83,7 +87,7 @@ where evaluated_moves, accepted_labels, mut accepted_trace_tokens, - } = evaluate_candidates( + } = evaluator.evaluate_candidates( &mut cursor, resources, &mut step_scope, @@ -133,15 +137,25 @@ 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); step_scope.apply_committed_change(|score_director| { cursor.apply_owned_candidate(selected_index, score_director); }); + evaluator.step_committed( + 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/phase/localsearch/phase/tests/common.rs b/crates/solverforge-solver/src/phase/localsearch/phase/tests/common.rs index b4733f1c9..9c224ef83 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; @@ -178,7 +179,7 @@ fn test_local_search_hill_climbing() { let final_score = solver_scope.best_score().copied().unwrap_or(initial_score); assert!(final_score >= initial_score); - assert!(solver_scope.stats().moves_evaluated > 0); + assert!(solver_scope.stats().moves_evaluated() > 0); } #[test] @@ -251,5 +252,5 @@ fn test_local_search_step_limit() { phase.solve(&mut solver_scope); - assert!(solver_scope.stats().step_count <= 3); + assert!(solver_scope.stats().step_count() <= 3); } diff --git a/crates/solverforge-solver/src/phase/localsearch/phase/tests/foraging.rs b/crates/solverforge-solver/src/phase/localsearch/phase/tests/foraging.rs index 88875ec90..d01386673 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/tests/foraging.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/tests/foraging.rs @@ -12,8 +12,8 @@ fn accepted_count_one_evaluates_one_accepted_move_per_step() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(1)) @@ -35,8 +35,8 @@ fn cancellation_before_next_candidate_does_not_commit_selected_move() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_applied, 0); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 0); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(0)) @@ -57,8 +57,8 @@ fn accepted_count_limit_picks_best_of_accepted_horizon() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 2); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 2); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(3)) @@ -80,8 +80,8 @@ fn config_move_count_interrupt_commits_best_already_accepted_move() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 1); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 1); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(1)) @@ -102,8 +102,8 @@ fn best_score_forager_still_scans_full_neighborhood() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 3); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 3); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(3)) @@ -124,8 +124,8 @@ fn score_improvement_required_move_rejects_worse_before_acceptor() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 2); - assert_eq!(solver_scope.stats().moves_applied, 1); + assert_eq!(solver_scope.stats().moves_evaluated(), 2); + assert_eq!(solver_scope.stats().moves_applied(), 1); assert_eq!( solver_scope.working_solution().score, Some(SoftScore::of(3)) 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 ca3e3d4e5..bf8b163c2 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/phase/tests/streaming.rs b/crates/solverforge-solver/src/phase/localsearch/phase/tests/streaming.rs index 00370af0f..6425889ac 100644 --- a/crates/solverforge-solver/src/phase/localsearch/phase/tests/streaming.rs +++ b/crates/solverforge-solver/src/phase/localsearch/phase/tests/streaming.rs @@ -13,7 +13,7 @@ fn test_local_search_records_selector_open_time_as_generation_time() { phase.solve(&mut solver_scope); assert!(solver_scope.stats().generation_time() >= Duration::from_millis(20)); - assert_eq!(solver_scope.stats().moves_generated, 1); + assert_eq!(solver_scope.stats().moves_generated(), 1); } #[test] @@ -41,7 +41,7 @@ fn local_search_reports_progress_without_an_8192_move_batch() { phase.solve(&mut solver_scope); - assert_eq!(solver_scope.stats().moves_evaluated, 3); + assert_eq!(solver_scope.stats().moves_evaluated(), 3); assert!( progress_events.load(Ordering::SeqCst) > 0, "local search ended without reporting its completed steps" diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/phase.rs index 38d98c48e..1074dd86d 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) { diff --git a/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs b/crates/solverforge-solver/src/phase/localsearch/vnd/tests.rs index 0f93055a4..438d66ab3 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")], - ) + )) } } @@ -493,8 +497,8 @@ fn vnd_rejects_hard_neutral_repair_move_when_hard_improvement_required() { Some(HardSoftScore::of(-1, 0)) ); let stats = solver_scope.stats(); - assert_eq!(stats.moves_generated, 1); - assert_eq!(stats.moves_evaluated, 1); - assert_eq!(stats.moves_acceptor_rejected, 1); - assert_eq!(stats.moves_hard_neutral, 1); + assert_eq!(stats.moves_generated(), 1); + assert_eq!(stats.moves_evaluated(), 1); + assert_eq!(stats.moves_acceptor_rejected(), 1); + assert_eq!(stats.moves_hard_neutral(), 1); } diff --git a/crates/solverforge-solver/src/run.rs b/crates/solverforge-solver/src/run.rs index 9e0223e12..b9d4768cd 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, @@ -419,10 +431,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 +486,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 +547,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, @@ -545,18 +561,24 @@ 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); + let main_constraints = constraints(&solution); let director = ScoreDirector::with_descriptor( solution, - constraints, - descriptor.clone(), + main_constraints, + ReplicaDescriptor::new(&descriptor, constraints), entity_count_by_descriptor, ); - 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); @@ -603,7 +625,7 @@ where stats, } = result; let final_telemetry = stats.snapshot(); - let final_move_speed = whole_units_per_second(stats.moves_evaluated, stats.elapsed()); + let final_move_speed = whole_units_per_second(stats.moves_evaluated(), stats.elapsed()); match terminal_reason { SolverTerminalReason::Completed | SolverTerminalReason::TerminatedByConfig => { runtime.emit_completed( @@ -630,11 +652,11 @@ where info!( event = "solve_end", score = %final_score, - steps = stats.step_count, - moves_generated = stats.moves_generated, - moves_evaluated = stats.moves_evaluated, - moves_accepted = stats.moves_accepted, - score_calculations = stats.score_calculations, + steps = stats.step_count(), + moves_generated = stats.moves_generated(), + moves_evaluated = stats.moves_evaluated(), + moves_accepted = stats.moves_accepted(), + score_calculations = stats.score_calculations(), generation_time = %format_duration(stats.generation_time()), evaluation_time = %format_duration(stats.evaluation_time()), moves_speed = final_move_speed, diff --git a/crates/solverforge-solver/src/run_tests.rs b/crates/solverforge-solver/src/run_tests.rs index 8e44fb1b1..9d97cb625 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(), diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/list_construction/phase.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_construction/phase.rs index dc58e85a5..b7b716bb9 100644 --- a/crates/solverforge-solver/src/runtime/compiler/executor/list_construction/phase.rs +++ b/crates/solverforge-solver/src/runtime/compiler/executor/list_construction/phase.rs @@ -201,6 +201,6 @@ mod tests { assert_eq!(first_routes, vec![vec![2, 1, 0]]); assert_eq!(scope.working_solution().routes, first_routes); - assert!(scope.stats().score_calculations >= 6); + assert!(scope.stats().score_calculations() >= 6); } } 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 8fe709484..dd0dc4f6d 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 c90eb8189..4f0aa0cea 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; @@ -6,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, @@ -54,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 @@ -126,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>( @@ -165,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 { @@ -369,8 +404,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 +462,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/list_leaf/tests/moves.rs b/crates/solverforge-solver/src/runtime/compiler/executor/list_leaf/tests/moves.rs index f83ac4246..9dd8ed386 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/runtime/compiler/executor/local_search/move.rs b/crates/solverforge-solver/src/runtime/compiler/executor/local_search/move.rs index a0778a9af..b57f6dd90 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; @@ -15,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 @@ -129,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)) => { @@ -210,7 +255,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), diff --git a/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs b/crates/solverforge-solver/src/runtime/compiler/executor/mod.rs index f333e537f..641258a9a 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, 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 c87985149..c59b94089 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,6 +96,7 @@ where &mut acceptor, &mut forager, None, + *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 c07e29083..fcb86c730 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, )) diff --git a/crates/solverforge-solver/src/scope/phase.rs b/crates/solverforge-solver/src/scope/phase.rs index d584c67ee..aa233b9eb 100644 --- a/crates/solverforge-solver/src/scope/phase.rs +++ b/crates/solverforge-solver/src/scope/phase.rs @@ -2,7 +2,6 @@ use std::cmp::Ordering; use std::time::Duration; -use std::time::Instant; use solverforge_core::domain::PlanningSolution; use solverforge_scoring::Director; @@ -34,32 +33,15 @@ pub struct PhaseScope<'t, 'a, S: PlanningSolution, D: Director, BestCb = ()> starting_score: Option, // Number of steps in this phase. step_count: u64, - // When this phase started. - start_time: Instant, - // Solver-active elapsed at phase entry. The solver clock excludes pauses. - solver_elapsed_at_start: Option, - // Phase statistics. - stats: PhaseStats, + // Phase type name, also the type of the phase's statistics record. + phase_type: &'static str, } impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> PhaseScope<'t, 'a, S, D, BestCb> { pub fn new(solver_scope: &'a mut SolverScope<'t, S, D, BestCb>, phase_index: usize) -> Self { - let candidate_trace_phase_index = solver_scope.begin_candidate_trace_phase(); - solver_scope.begin_phase_progress(phase_index, "Unknown", 0, 0); - let starting_score = solver_scope.best_score().cloned(); - let solver_elapsed_at_start = solver_scope.elapsed(); - Self { - solver_scope, - phase_index, - candidate_trace_phase_index, - starting_score, - step_count: 0, - start_time: Instant::now(), - solver_elapsed_at_start, - stats: PhaseStats::new(phase_index, "Unknown"), - } + Self::with_phase_type(solver_scope, phase_index, "Unknown") } pub fn with_phase_type( @@ -69,17 +51,15 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> ) -> Self { let candidate_trace_phase_index = solver_scope.begin_candidate_trace_phase(); solver_scope.begin_phase_progress(phase_index, phase_type, 0, 0); + solver_scope.stats_mut().begin_phase(phase_type); let starting_score = solver_scope.best_score().cloned(); - let solver_elapsed_at_start = solver_scope.elapsed(); Self { solver_scope, phase_index, candidate_trace_phase_index, starting_score, step_count: 0, - start_time: Instant::now(), - solver_elapsed_at_start, - stats: PhaseStats::new(phase_index, phase_type), + phase_type, } } @@ -91,11 +71,13 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> self.starting_score.as_ref() } + /// Solver-active time this phase has spent; the solver clock excludes + /// pauses. pub fn elapsed(&self) -> std::time::Duration { - match (self.solver_elapsed_at_start, self.solver_scope.elapsed()) { - (Some(start), Some(now)) => now.saturating_sub(start), - _ => self.start_time.elapsed(), - } + self.solver_scope + .stats() + .phase_elapsed() + .expect("a phase scope keeps its phase record open") } pub fn step_count(&self) -> u64 { @@ -105,7 +87,6 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> /// Increments the phase step count. pub fn increment_step_count(&mut self) -> u64 { self.step_count += 1; - self.stats.record_step(); self.solver_scope.increment_step_count(); self.step_count } @@ -135,7 +116,6 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> /// Calculates the current score. pub fn calculate_score(&mut self) -> S::Score { - self.stats.record_score_calculation(); self.solver_scope.calculate_score() } @@ -149,28 +129,39 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> self.solver_scope.promote_current_solution_on_score_tie() } + /// Statistics of this phase. They live in the solver's statistics, which + /// open this phase's record for the scope's lifetime. pub fn stats(&self) -> &PhaseStats { - &self.stats + self.solver_scope + .stats() + .phase() + .expect("a phase scope keeps its phase record open") } pub fn stats_mut(&mut self) -> &mut PhaseStats { - &mut self.stats + self.solver_scope + .stats_mut() + .phase_mut() + .expect("a phase scope keeps its phase record open") } pub fn report_progress(&self) { - let phase = self.stats.snapshot_with_elapsed(self.elapsed()); - self.solver_scope.report_phase_progress(phase); + self.solver_scope.report_phase_progress(); } pub fn report_progress_if_due(&mut self) -> bool { if !BestCb::PUBLISHES_PROGRESS && !tracing::enabled!(tracing::Level::DEBUG) { return false; } + let (step_count, moves_evaluated) = { + let stats = self.stats(); + (stats.step_count, stats.moves_evaluated) + }; let tick = self.solver_scope.take_phase_progress_tick( self.phase_index, - self.stats.phase_type, - self.stats.step_count, - self.stats.moves_evaluated, + self.phase_type, + step_count, + moves_evaluated, ); let Some(tick) = tick else { return false; @@ -194,19 +185,20 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> let best_score = reported_best_score .map(ToString::to_string) .unwrap_or_else(|| "none".to_string()); + let stats = self.stats(); debug!( event = "progress", - phase = self.stats.phase_type, + phase = self.phase_type, phase_index = self.phase_index, - steps = self.stats.step_count, - moves_generated = self.stats.moves_generated, - moves_evaluated = self.stats.moves_evaluated, - moves_accepted = self.stats.moves_accepted, - moves_score_improving = self.stats.moves_score_improving(), - moves_applied_improving = self.stats.moves_applied_improving(), - score_calculations = self.stats.score_calculations, + steps = stats.step_count, + moves_generated = stats.moves_generated, + moves_evaluated = stats.moves_evaluated, + moves_accepted = stats.moves_accepted, + moves_score_improving = stats.moves_score_improving(), + moves_applied_improving = stats.moves_applied_improving(), + score_calculations = stats.score_calculations, speed = whole_units_per_second(speed_count, tick.elapsed), - acceptance_rate = format!("{:.1}%", self.stats.acceptance_rate() * 100.0), + acceptance_rate = format!("{:.1}%", stats.acceptance_rate() * 100.0), current_score = current_score, best_score = best_score, ); @@ -215,7 +207,6 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> } pub fn record_generated_batch(&mut self, count: u64, duration: Duration) { - self.stats.record_generated_batch(count, duration); self.solver_scope .stats_mut() .record_generated_batch(count, duration); @@ -226,8 +217,6 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> } pub fn record_selector_generated_move(&mut self, selector_index: usize, duration: Duration) { - self.stats - .record_selector_generated(selector_index, 1, duration); self.solver_scope .stats_mut() .record_selector_generated(selector_index, 1, duration); @@ -239,96 +228,75 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> selector_label: impl Into, duration: Duration, ) { - let selector_label = selector_label.into(); - self.stats.record_selector_generated_with_label( - selector_index, - selector_label.clone(), - 1, - duration, - ); self.solver_scope .stats_mut() .record_selector_generated_with_label(selector_index, selector_label, 1, duration); } pub fn record_generation_time(&mut self, duration: Duration) { - self.stats.record_generation_time(duration); self.solver_scope .stats_mut() .record_generation_time(duration); } pub fn record_evaluated_move(&mut self, duration: Duration) { - self.stats.record_evaluated_move(duration); self.solver_scope.record_evaluated_move(duration); } pub fn record_selector_evaluated_move(&mut self, selector_index: usize, duration: Duration) { - self.stats - .record_selector_evaluated(selector_index, duration); self.solver_scope .record_selector_evaluated(selector_index, duration); } pub fn record_move_accepted(&mut self) { - self.stats.record_move_accepted(); self.solver_scope.stats_mut().record_move_accepted(); } pub fn record_move_applied(&mut self) { - self.stats.record_move_applied(); self.solver_scope.stats_mut().record_move_applied(); } pub fn record_selector_move_accepted(&mut self, selector_index: usize) { - self.stats.record_selector_accepted(selector_index); self.solver_scope .stats_mut() .record_selector_accepted(selector_index); } pub fn record_selector_move_applied(&mut self, selector_index: usize) { - self.stats.record_selector_applied(selector_index); self.solver_scope .stats_mut() .record_selector_applied(selector_index); } pub fn record_move_not_doable(&mut self) { - self.stats.record_move_not_doable(); self.solver_scope.stats_mut().record_move_not_doable(); } pub fn record_selector_move_not_doable(&mut self, selector_index: usize) { - self.stats.record_selector_not_doable(selector_index); self.solver_scope .stats_mut() .record_selector_not_doable(selector_index); } pub fn record_move_acceptor_rejected(&mut self) { - self.stats.record_move_acceptor_rejected(); self.solver_scope .stats_mut() .record_move_acceptor_rejected(); } pub fn record_selector_move_acceptor_rejected(&mut self, selector_index: usize) { - self.stats.record_selector_acceptor_rejected(selector_index); self.solver_scope .stats_mut() .record_selector_acceptor_rejected(selector_index); } pub fn record_moves_forager_ignored(&mut self, count: u64) { - self.stats.record_moves_forager_ignored(count); self.solver_scope .stats_mut() .record_moves_forager_ignored(count); } pub fn record_move_kind_generated(&mut self, move_label: &'static str) { - self.stats.record_move_kind_generated(move_label); self.solver_scope .stats_mut() .record_move_kind_generated(move_label); @@ -339,37 +307,30 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> move_label: &'static str, score_ordering: Ordering, ) { - self.stats - .record_move_kind_evaluated(move_label, score_ordering); self.solver_scope .stats_mut() .record_move_kind_evaluated(move_label, score_ordering); } pub fn record_move_kind_evaluated_unscored(&mut self, move_label: &'static str) { - self.stats.record_move_kind_evaluated_unscored(move_label); self.solver_scope .stats_mut() .record_move_kind_evaluated_unscored(move_label); } pub fn record_move_kind_accepted(&mut self, move_label: &'static str) { - self.stats.record_move_kind_accepted(move_label); self.solver_scope .stats_mut() .record_move_kind_accepted(move_label); } pub fn record_move_kind_applied(&mut self, move_label: &'static str, score_improvement: f64) { - self.stats - .record_move_kind_applied(move_label, score_improvement); self.solver_scope .stats_mut() .record_move_kind_applied(move_label, score_improvement); } pub fn record_move_kind_not_doable(&mut self, move_label: &'static str) { - self.stats.record_move_kind_not_doable(move_label); self.solver_scope .stats_mut() .record_move_kind_not_doable(move_label); @@ -380,30 +341,25 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> move_label: &'static str, score_ordering: Ordering, ) { - self.stats - .record_move_kind_acceptor_rejected(move_label, score_ordering); self.solver_scope .stats_mut() .record_move_kind_acceptor_rejected(move_label, score_ordering); } pub fn record_move_kind_forager_ignored(&mut self, move_label: &'static str, count: u64) { - self.stats - .record_move_kind_forager_ignored(move_label, count); self.solver_scope .stats_mut() .record_move_kind_forager_ignored(move_label, count); } pub fn record_applied_move_trace(&mut self, applied_move: AppliedMoveTelemetry) { - self.stats.record_applied_move_trace(applied_move); self.solver_scope .stats_mut() .record_applied_move_trace(applied_move); } pub fn can_record_applied_move_trace(&self) -> bool { - self.stats.can_record_applied_move_trace() + self.stats().can_record_applied_move_trace() } #[allow(clippy::too_many_arguments)] @@ -421,7 +377,7 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> self.solver_scope.record_candidate_pull( source, self.candidate_trace_phase_index.unwrap_or(self.phase_index), - self.stats.phase_type, + self.phase_type, self.step_count, selector_index, candidate_index, @@ -448,7 +404,7 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> self.solver_scope.record_candidate_operation( source, self.candidate_trace_phase_index.unwrap_or(self.phase_index), - self.stats.phase_type, + self.phase_type, self.step_count, selector_index, candidate_index, @@ -469,41 +425,40 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> } pub fn record_move_hard_improving(&mut self) { - self.stats.record_move_hard_improving(); self.solver_scope.stats_mut().record_move_hard_improving(); } pub fn record_move_hard_neutral(&mut self) { - self.stats.record_move_hard_neutral(); self.solver_scope.stats_mut().record_move_hard_neutral(); } pub fn record_move_hard_worse(&mut self) { - self.stats.record_move_hard_worse(); self.solver_scope.stats_mut().record_move_hard_worse(); } pub fn record_score_calculation(&mut self) { - self.stats.record_score_calculation(); 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 .stats_mut() .record_construction_slot_assigned(); } pub fn record_construction_slot_kept(&mut self) { - self.stats.record_construction_slot_kept(); self.solver_scope .stats_mut() .record_construction_slot_kept(); } pub fn record_construction_slot_no_doable(&mut self) { - self.stats.record_construction_slot_no_doable(); self.solver_scope .stats_mut() .record_construction_slot_no_doable(); @@ -514,10 +469,14 @@ impl<'t, 'a, S: PlanningSolution, D: Director, BestCb: ProgressCallback> group_name: &'static str, count: u64, ) { - self.stats - .record_scalar_assignment_required_remaining(count); self.solver_scope .stats_mut() .record_scalar_assignment_required_remaining(group_name, count); } } + +impl, BestCb> Drop for PhaseScope<'_, '_, S, D, BestCb> { + fn drop(&mut self) { + self.solver_scope.end_phase_stats(); + } +} diff --git a/crates/solverforge-solver/src/scope/solver.rs b/crates/solverforge-solver/src/scope/solver.rs index c584f97fe..bd400d04b 100644 --- a/crates/solverforge-solver/src/scope/solver.rs +++ b/crates/solverforge-solver/src/scope/solver.rs @@ -25,3 +25,11 @@ use crate::stats::{ include!("solver/progress.rs"); include!("solver/scope_core.rs"); include!("solver/scope_progress.rs"); + +impl, ProgressCb> SolverScope<'_, S, D, ProgressCb> { + /// Closes the running phase's statistics record. Bound-free so a phase + /// scope can close its record when it drops. + pub(crate) fn end_phase_stats(&mut self) { + self.stats.end_phase(); + } +} diff --git a/crates/solverforge-solver/src/scope/solver/scope_core.rs b/crates/solverforge-solver/src/scope/solver/scope_core.rs index dcba0e4e1..5b818a7f6 100644 --- a/crates/solverforge-solver/src/scope/solver/scope_core.rs +++ b/crates/solverforge-solver/src/scope/solver/scope_core.rs @@ -80,8 +80,6 @@ pub struct SolverScope<'t, S: PlanningSolution, D: Director, ProgressCb = ()> current_score: Option, best_score: Option, rng: StdRng, - start_time: Option, - paused_at: Option, total_step_count: u64, terminate: Option<&'t AtomicBool>, runtime: Option>, @@ -98,6 +96,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, @@ -136,11 +139,11 @@ impl PhaseBudget { ), move_count_limit: remaining_limit( scope.inphase_move_count_limit, - scope.stats.moves_evaluated, + scope.stats.moves_evaluated(), ), score_calc_count_limit: remaining_limit( scope.inphase_score_calc_count_limit, - scope.stats.score_calculations, + scope.stats.score_calculations(), ), step_count: AtomicU64::new(0), moves_evaluated: AtomicU64::new(0), @@ -359,8 +362,6 @@ impl<'t, S: PlanningSolution, D: Director> SolverScope<'t, S, D, ()> { current_score: None, best_score: None, rng: StdRng::from_rng(&mut rand::rng()), - start_time: None, - paused_at: None, total_step_count: 0, terminate: None, runtime: None, @@ -377,6 +378,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, @@ -404,8 +406,6 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> current_score: None, best_score: None, rng: StdRng::from_rng(&mut rand::rng()), - start_time: None, - paused_at: None, total_step_count: 0, terminate, runtime, @@ -422,6 +422,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, @@ -551,7 +552,8 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> fn child_time_deadline(&self) -> Option { self.time_deadline.or_else(|| { self.time_limit.map(|limit| { - self.start_time + self.stats + .started_at() .map(|start| start + limit) .unwrap_or_else(|| Instant::now() + limit) }) @@ -568,8 +570,6 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> current_score: self.current_score, best_score: self.best_score, rng: self.rng, - start_time: self.start_time, - paused_at: self.paused_at, total_step_count: self.total_step_count, terminate: self.terminate, runtime: self.runtime, @@ -586,6 +586,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, @@ -597,8 +598,6 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn start_solving(&mut self) { - self.start_time = Some(Instant::now()); - self.paused_at = None; self.total_step_count = 0; self.terminal_reason = None; self.last_best_elapsed = None; @@ -611,11 +610,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn elapsed(&self) -> Option { - match (self.start_time, self.paused_at) { - (Some(start), Some(paused_at)) => Some(paused_at.duration_since(start)), - (Some(start), None) => Some(start.elapsed()), - _ => None, - } + self.stats.active_elapsed() } pub fn time_since_last_improvement(&self) -> Option { @@ -665,7 +660,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn initialize_working_solution_as_best(&mut self) -> S::Score { - if self.start_time.is_none() { + if self.stats.started_at().is_none() { self.start_solving(); } let score = self.calculate_score(); @@ -675,6 +670,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 +727,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 08af659a3..eec96fa04 100644 --- a/crates/solverforge-solver/src/scope/solver/scope_progress.rs +++ b/crates/solverforge-solver/src/scope/solver/scope_progress.rs @@ -11,6 +11,9 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn report_progress(&self) { + if !ProgressCb::PUBLISHES_PROGRESS { + return; + } let best_score = self .best_solution_publication_enabled .then_some(self.best_score.as_ref()) @@ -55,13 +58,15 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> pulse.take_due(now, phase_index, phase_type, step_count, move_count) } - pub(crate) fn report_phase_progress(&self, phase: crate::stats::PhaseTelemetry) { + pub(crate) fn report_phase_progress(&self) { + if !ProgressCb::PUBLISHES_PROGRESS { + return; + } let best_score = self .best_solution_publication_enabled .then_some(self.best_score.as_ref()) .flatten(); - let mut telemetry = self.stats.snapshot_without_applied_move_trace(); - telemetry.phase = Some(phase); + let telemetry = self.stats.snapshot_without_applied_move_trace(); self.progress_callback.invoke(SolverProgressRef { kind: SolverProgressKind::Progress, status: self.progress_state(), @@ -73,7 +78,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn report_best_solution(&self) { - if !self.best_solution_publication_enabled { + if !ProgressCb::PUBLISHES_PROGRESS || !self.best_solution_publication_enabled { return; } self.progress_callback.invoke(SolverProgressRef { @@ -123,7 +128,7 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn set_best_solution(&mut self, solution: S, score: S::Score) { - if self.start_time.is_none() { + if self.stats.started_at().is_none() { self.start_solving(); } self.current_score = Some(score); @@ -272,20 +277,11 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> } pub fn pause_timers(&mut self) { - if self.paused_at.is_none() { - self.paused_at = Some(Instant::now()); - self.stats.pause(); - } + self.stats.pause(); } pub fn resume_timers(&mut self) { - if let Some(paused_at) = self.paused_at.take() { - let paused_for = paused_at.elapsed(); - if let Some(start) = self.start_time { - self.start_time = Some(start + paused_for); - } - self.stats.resume(); - } + self.stats.resume(); } pub fn should_terminate_construction(&mut self) -> bool { @@ -520,6 +516,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 { @@ -583,12 +583,12 @@ impl<'t, S: PlanningSolution, D: Director, ProgressCb: ProgressCallback> fn inphase_move_count_limit_reached(&self) -> bool { self.inphase_move_count_limit - .is_some_and(|limit| self.stats.moves_evaluated >= limit) + .is_some_and(|limit| self.stats.moves_evaluated() >= limit) } fn inphase_score_calc_count_limit_reached(&self) -> bool { self.inphase_score_calc_count_limit - .is_some_and(|limit| self.stats.score_calculations >= limit) + .is_some_and(|limit| self.stats.score_calculations() >= limit) } fn advance_solution_revision(&mut self) { @@ -606,6 +606,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 afadb72c5..ddb451e51 100644 --- a/crates/solverforge-solver/src/scope/tests.rs +++ b/crates/solverforge-solver/src/scope/tests.rs @@ -168,6 +168,9 @@ fn test_phase_scope() { } assert_eq!(solver_scope.total_step_count(), 1); + let phases = solver_scope.stats().snapshot().phases; + assert_eq!(phases.len(), 1, "a dropped phase scope closes its record"); + assert_eq!(phases[0].phase_type, "Unknown"); } #[test] @@ -200,7 +203,11 @@ fn phase_progress_elapsed_uses_the_pause_aware_solver_clock() { .unwrap() .pop() .expect("phase progress is published"); - let phase = telemetry.phase.expect("phase telemetry is attached"); + let phase = telemetry + .phases + .last() + .cloned() + .expect("phase telemetry is attached"); assert_eq!(phase.phase_type, "PauseAwarePhase"); assert_eq!(phase.elapsed, frozen_elapsed); assert!(phase.elapsed < raw_after); @@ -429,3 +436,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); +} diff --git a/crates/solverforge-solver/src/solver.rs b/crates/solverforge-solver/src/solver.rs index a71151563..065e83847 100644 --- a/crates/solverforge-solver/src/solver.rs +++ b/crates/solverforge-solver/src/solver.rs @@ -65,15 +65,15 @@ impl SolveResult { } pub fn step_count(&self) -> u64 { - self.stats.step_count + self.stats.step_count() } pub fn moves_evaluated(&self) -> u64 { - self.stats.moves_evaluated + self.stats.moves_evaluated() } pub fn moves_accepted(&self) -> u64 { - self.stats.moves_accepted + self.stats.moves_accepted() } } diff --git a/crates/solverforge-solver/src/stats/candidate_trace.rs b/crates/solverforge-solver/src/stats/candidate_trace.rs index d5649d4da..a8543f8b0 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/phase.rs b/crates/solverforge-solver/src/stats/phase.rs index c64a06588..c0c248dd3 100644 --- a/crates/solverforge-solver/src/stats/phase.rs +++ b/crates/solverforge-solver/src/stats/phase.rs @@ -6,7 +6,10 @@ use super::{AppliedMoveTelemetry, MoveTelemetry, PhaseTelemetry, SelectorTelemet const APPLIED_MOVE_TRACE_LIMIT: usize = 8; -#[derive(Debug)] +#[path = "phase_absorb.rs"] +mod absorb; + +#[derive(Debug, Clone)] pub struct PhaseStats { // Index of this phase (0-based). pub phase_index: usize, @@ -20,6 +23,9 @@ pub struct PhaseStats { pub moves_generated: u64, // Number of moves evaluated in this phase. pub moves_evaluated: u64, + /// Candidates parallel evaluation evaluated ahead of the sequential + /// decision point in this phase and then discarded. + pub moves_speculative: u64, // Number of moves accepted in this phase. pub moves_accepted: u64, // Number of moves applied in this phase. @@ -59,6 +65,7 @@ impl PhaseStats { step_count: 0, moves_generated: 0, moves_evaluated: 0, + moves_speculative: 0, moves_accepted: 0, moves_applied: 0, moves_not_doable: 0, @@ -102,6 +109,7 @@ impl PhaseStats { 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(), @@ -241,6 +249,12 @@ impl PhaseStats { 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; } @@ -295,6 +309,10 @@ impl PhaseStats { &self.applied_move_trace } + pub fn move_telemetry(&self) -> Vec { + self.move_stats.values().cloned().collect() + } + pub fn moves_score_improving(&self) -> u64 { self.move_stats .values() diff --git a/crates/solverforge-solver/src/stats/phase_absorb.rs b/crates/solverforge-solver/src/stats/phase_absorb.rs new file mode 100644 index 000000000..4246a3a12 --- /dev/null +++ b/crates/solverforge-solver/src/stats/phase_absorb.rs @@ -0,0 +1,89 @@ +/* Folding one phase's statistics into an accumulator. + +Run totals are the fold of every finished phase into one accumulator, so a +counter recorded once per phase can never disagree with its run total. +*/ + +use super::{PhaseStats, APPLIED_MOVE_TRACE_LIMIT}; +use crate::stats::{MoveTelemetry, SelectorTelemetry}; + +impl PhaseStats { + /// Adds `other`'s counters, durations, selector and move-kind entries, + /// and applied-move trace to `self`. The required-remaining gauge is a + /// level, not a count, so it takes `other`'s value instead of a sum. + pub(crate) fn absorb(&mut self, other: &PhaseStats) { + self.step_count += other.step_count; + self.moves_generated += other.moves_generated; + self.moves_evaluated += other.moves_evaluated; + self.moves_speculative += other.moves_speculative; + self.moves_accepted += other.moves_accepted; + self.moves_applied += other.moves_applied; + self.moves_not_doable += other.moves_not_doable; + self.moves_acceptor_rejected += other.moves_acceptor_rejected; + self.moves_forager_ignored += other.moves_forager_ignored; + self.moves_hard_improving += other.moves_hard_improving; + self.moves_hard_neutral += other.moves_hard_neutral; + self.moves_hard_worse += other.moves_hard_worse; + self.conflict_repair_provider_generated += other.conflict_repair_provider_generated; + self.conflict_repair_duplicate_filtered += other.conflict_repair_duplicate_filtered; + self.conflict_repair_illegal_filtered += other.conflict_repair_illegal_filtered; + self.conflict_repair_not_doable_filtered += other.conflict_repair_not_doable_filtered; + self.conflict_repair_hard_improving += other.conflict_repair_hard_improving; + self.conflict_repair_exposed += other.conflict_repair_exposed; + self.score_calculations += other.score_calculations; + self.construction_slots_assigned += other.construction_slots_assigned; + self.construction_slots_kept += other.construction_slots_kept; + self.construction_slots_no_doable += other.construction_slots_no_doable; + self.scalar_assignment_required_remaining = other.scalar_assignment_required_remaining; + self.generation_time += other.generation_time; + self.evaluation_time += other.evaluation_time; + for selector in &other.selector_stats { + let entry = self + .selector_stats_entry_with_label(selector.selector_index, &selector.selector_label); + add_selector(entry, selector); + } + for (label, entry) in &other.move_stats { + add_move(self.move_stats_entry(label), entry); + } + let room = APPLIED_MOVE_TRACE_LIMIT.saturating_sub(self.applied_move_trace.len()); + self.applied_move_trace + .extend(other.applied_move_trace.iter().take(room).copied()); + } +} + +fn add_selector(into: &mut SelectorTelemetry, from: &SelectorTelemetry) { + into.moves_generated += from.moves_generated; + into.moves_evaluated += from.moves_evaluated; + into.moves_accepted += from.moves_accepted; + into.moves_applied += from.moves_applied; + into.moves_not_doable += from.moves_not_doable; + into.moves_acceptor_rejected += from.moves_acceptor_rejected; + into.moves_forager_ignored += from.moves_forager_ignored; + into.moves_hard_improving += from.moves_hard_improving; + into.moves_hard_neutral += from.moves_hard_neutral; + into.moves_hard_worse += from.moves_hard_worse; + into.conflict_repair_provider_generated += from.conflict_repair_provider_generated; + into.conflict_repair_duplicate_filtered += from.conflict_repair_duplicate_filtered; + into.conflict_repair_illegal_filtered += from.conflict_repair_illegal_filtered; + into.conflict_repair_not_doable_filtered += from.conflict_repair_not_doable_filtered; + into.conflict_repair_hard_improving += from.conflict_repair_hard_improving; + into.conflict_repair_exposed += from.conflict_repair_exposed; + into.generation_time += from.generation_time; + into.evaluation_time += from.evaluation_time; +} + +fn add_move(into: &mut MoveTelemetry, from: &MoveTelemetry) { + into.moves_generated += from.moves_generated; + into.moves_evaluated += from.moves_evaluated; + into.moves_accepted += from.moves_accepted; + into.moves_applied += from.moves_applied; + into.moves_not_doable += from.moves_not_doable; + into.moves_acceptor_rejected += from.moves_acceptor_rejected; + into.moves_forager_ignored += from.moves_forager_ignored; + into.moves_score_improving += from.moves_score_improving; + into.moves_applied_improving += from.moves_applied_improving; + into.moves_score_equal += from.moves_score_equal; + into.moves_score_worse += from.moves_score_worse; + into.moves_rejected_improving += from.moves_rejected_improving; + into.applied_score_improvement += from.applied_score_improvement; +} diff --git a/crates/solverforge-solver/src/stats/solver.rs b/crates/solverforge-solver/src/stats/solver.rs index 99c9b117b..cb6615199 100644 --- a/crates/solverforge-solver/src/stats/solver.rs +++ b/crates/solverforge-solver/src/stats/solver.rs @@ -2,56 +2,93 @@ use std::cmp::Ordering; use std::collections::BTreeMap; use std::time::{Duration, Instant}; +use smallvec::SmallVec; + use super::{ AppliedMoveTelemetry, CandidatePullTelemetry, CandidateTraceDisposition, CandidateTracePhasePlan, CandidateTracePullToken, CandidateTraceRecordDecision, - CandidateTraceTelemetry, MoveTelemetry, SelectorTelemetry, SolverTelemetry, Throughput, + CandidateTraceTelemetry, PhaseStats, PhaseTelemetry, SolverTelemetry, Throughput, }; -const APPLIED_MOVE_TRACE_LIMIT: usize = 8; +/* Solve-wide statistics, kept per phase. -#[derive(Debug, Default)] +Every counter is recorded exactly once, into the phase that is running, or +into the solve-wide accumulator when no phase is (the initial score +calculation, for example). A finished phase folds into that accumulator and +moves into the finished list, so run totals are always the accumulator plus +the running phase, and the per-phase records always sum to them. Finished +phases stay `PhaseStats` until a snapshot asks for them, and the list keeps a +typical solve's phases inline, so closing a phase never allocates. +*/ +#[derive(Debug)] pub struct SolverStats { start_time: Option, pause_started_at: Option, - // Total steps taken across all phases. - pub step_count: u64, - /// Total candidate moves actually yielded by cursors across all phases. - /// Unrequested logical neighborhood tails are not generated work. - pub moves_generated: u64, - // Total moves evaluated across all phases. - pub moves_evaluated: u64, - // Total moves accepted across all phases. - pub moves_accepted: u64, - // Total moves applied across all phases. - pub moves_applied: u64, - pub moves_not_doable: u64, - pub moves_acceptor_rejected: u64, - pub moves_forager_ignored: u64, - pub moves_hard_improving: u64, - pub moves_hard_neutral: u64, - pub moves_hard_worse: u64, - pub conflict_repair_provider_generated: u64, - pub conflict_repair_duplicate_filtered: u64, - pub conflict_repair_illegal_filtered: u64, - pub conflict_repair_not_doable_filtered: u64, - pub conflict_repair_hard_improving: u64, - pub conflict_repair_exposed: u64, - // Total score calculations performed. - pub score_calculations: u64, - pub construction_slots_assigned: u64, - pub construction_slots_kept: u64, - pub construction_slots_no_doable: u64, - pub scalar_assignment_required_remaining: u64, + // Work outside any phase plus every finished phase, folded together. + folded: PhaseStats, + finished_phases: SmallVec<[FinishedPhase; INLINE_PHASES]>, + current_phase: Option, scalar_assignment_required_remaining_by_group: BTreeMap<&'static str, u64>, - generation_time: Duration, - evaluation_time: Duration, - selector_stats: Vec, - move_stats: BTreeMap<&'static str, MoveTelemetry>, - applied_move_trace: Vec, candidate_trace: Option, } +// Phases a solve keeps without allocating: construction, local search, and +// room for a construction or improvement phase on each side. +const INLINE_PHASES: usize = 4; + +#[derive(Debug)] +struct FinishedPhase { + stats: PhaseStats, + elapsed: Duration, +} + +#[derive(Debug)] +struct RunningPhase { + stats: PhaseStats, + // Solver-active elapsed at phase entry, when the solve clock had started. + started_at: Option, +} + +impl Default for SolverStats { + fn default() -> Self { + Self { + start_time: None, + pause_started_at: None, + folded: PhaseStats::new(0, "Solve"), + finished_phases: SmallVec::new(), + current_phase: None, + scalar_assignment_required_remaining_by_group: BTreeMap::new(), + candidate_trace: None, + } + } +} + +macro_rules! run_totals { + ($($(#[$doc:meta])* $name:ident),* $(,)?) => { + $( + $(#[$doc])* + pub fn $name(&self) -> u64 { + self.folded.$name + + self + .current_phase + .as_ref() + .map_or(0, |phase| phase.stats.$name) + } + )* + }; +} + +macro_rules! record_into_active { + ($($(#[$doc:meta])* $name:ident($($arg:ident: $ty:ty),*)),* $(,)?) => { + $( + $(#[$doc])* + pub fn $name(&mut self, $($arg: $ty),*) { + self.active().$name($($arg),*); + } + )* + }; +} + impl SolverStats { /// Marks the start of solving. pub fn start(&mut self) { @@ -59,14 +96,29 @@ impl SolverStats { self.pause_started_at = None; } + /// Solver-active time since `start`, excluding pauses; zero before it. pub fn elapsed(&self) -> Duration { + self.active_elapsed().unwrap_or_default() + } + + /// Solver-active time since `start`, or `None` before the solve started. + pub fn active_elapsed(&self) -> Option { match (self.start_time, self.pause_started_at) { - (Some(start), Some(paused_at)) => paused_at.duration_since(start), - (Some(start), None) => start.elapsed(), - _ => Duration::default(), + (Some(start), Some(paused_at)) => Some(paused_at.duration_since(start)), + (Some(start), None) => Some(start.elapsed()), + _ => None, } } + /// The start instant, shifted forward by every finished pause. + pub fn started_at(&self) -> Option { + self.start_time + } + + pub fn is_paused(&self) -> bool { + self.pause_started_at.is_some() + } + pub fn pause(&mut self) { if self.start_time.is_some() && self.pause_started_at.is_none() { self.pause_started_at = Some(Instant::now()); @@ -79,147 +131,165 @@ impl SolverStats { } } - /// Records one or more generated candidate moves and the time spent generating them. - pub fn record_generated_batch(&mut self, count: u64, duration: Duration) { - self.moves_generated += count; - self.generation_time += duration; - } - - pub fn record_selector_generated( - &mut self, - selector_index: usize, - count: u64, - duration: Duration, - ) { - self.record_generated_batch(count, duration); - let selector = self.selector_stats_entry(selector_index); - selector.moves_generated += count; - selector.generation_time += duration; - } - - /// Records generation time that did not itself yield a counted move. - pub fn record_generation_time(&mut self, duration: Duration) { - self.generation_time += duration; - } - - /// Records a single generated candidate move and the time spent generating it. - pub fn record_generated_move(&mut self, duration: Duration) { - self.record_generated_batch(1, duration); - } - - /// Records a move evaluation and the time spent evaluating it. - pub fn record_evaluated_move(&mut self, duration: Duration) { - self.moves_evaluated += 1; - self.evaluation_time += duration; - } - - pub fn record_selector_evaluated(&mut self, selector_index: usize, duration: Duration) { - self.record_evaluated_move(duration); - let selector = self.selector_stats_entry(selector_index); - selector.moves_evaluated += 1; - selector.evaluation_time += duration; - } - - /// Records an accepted move. - pub fn record_move_accepted(&mut self) { - self.moves_accepted += 1; - } - - pub fn record_selector_accepted(&mut self, selector_index: usize) { - self.record_move_accepted(); - self.selector_stats_entry(selector_index).moves_accepted += 1; - } - - pub fn record_move_applied(&mut self) { - self.moves_applied += 1; - } - - pub fn record_selector_applied(&mut self, selector_index: usize) { - self.record_move_applied(); - self.selector_stats_entry(selector_index).moves_applied += 1; - } - - pub fn record_move_not_doable(&mut self) { - self.moves_not_doable += 1; - } - - pub fn record_selector_not_doable(&mut self, selector_index: usize) { - self.record_move_not_doable(); - self.selector_stats_entry(selector_index).moves_not_doable += 1; - } - - pub fn record_move_acceptor_rejected(&mut self) { - self.moves_acceptor_rejected += 1; - } - - pub fn record_selector_acceptor_rejected(&mut self, selector_index: usize) { - self.record_move_acceptor_rejected(); - self.selector_stats_entry(selector_index) - .moves_acceptor_rejected += 1; - } - - pub fn record_moves_forager_ignored(&mut self, count: u64) { - self.moves_forager_ignored += count; - } - - pub fn record_move_hard_improving(&mut self) { - self.moves_hard_improving += 1; - } - - pub fn record_move_hard_neutral(&mut self) { - self.moves_hard_neutral += 1; - } - - pub fn record_move_hard_worse(&mut self) { - self.moves_hard_worse += 1; - } - - pub fn record_conflict_repair_provider_generated(&mut self, count: u64) { - self.conflict_repair_provider_generated += count; - } - - pub fn record_conflict_repair_duplicate_filtered(&mut self) { - self.conflict_repair_duplicate_filtered += 1; + /// Opens the next phase; counters recorded from now on belong to it. A + /// phase still running is finished first. + pub fn begin_phase(&mut self, phase_type: &'static str) { + self.end_phase(); + let phase_index = self.finished_phases.len(); + self.current_phase = Some(RunningPhase { + stats: PhaseStats::new(phase_index, phase_type), + started_at: self.active_elapsed(), + }); } - pub fn record_conflict_repair_illegal_filtered(&mut self) { - self.conflict_repair_illegal_filtered += 1; + /// Freezes the running phase's record and folds it into the run totals. + pub fn end_phase(&mut self) { + if let Some(phase) = self.current_phase.take() { + let elapsed = self.running_elapsed(&phase); + self.folded.absorb(&phase.stats); + self.finished_phases.push(FinishedPhase { + stats: phase.stats, + elapsed, + }); + } } - pub fn record_conflict_repair_not_doable_filtered(&mut self) { - self.conflict_repair_not_doable_filtered += 1; + /// Solver-active time the running phase has spent, if a phase is running. + pub fn phase_elapsed(&self) -> Option { + self.current_phase + .as_ref() + .map(|phase| self.running_elapsed(phase)) } - pub fn record_conflict_repair_hard_improving(&mut self) { - self.conflict_repair_hard_improving += 1; + /// Statistics of the phase that is running, if any. + pub fn phase(&self) -> Option<&PhaseStats> { + self.current_phase.as_ref().map(|phase| &phase.stats) } - pub fn record_conflict_repair_exposed(&mut self) { - self.conflict_repair_exposed += 1; + pub fn phase_mut(&mut self) -> Option<&mut PhaseStats> { + self.current_phase.as_mut().map(|phase| &mut phase.stats) } - /// Records a step completion. - pub fn record_step(&mut self) { - self.step_count += 1; + fn active(&mut self) -> &mut PhaseStats { + match self.current_phase.as_mut() { + Some(phase) => &mut phase.stats, + None => &mut self.folded, + } } - /// Records a score calculation. - pub fn record_score_calculation(&mut self) { - self.score_calculations += 1; + fn running_elapsed(&self, phase: &RunningPhase) -> Duration { + match (phase.started_at, self.active_elapsed()) { + (Some(started_at), Some(now)) => now.saturating_sub(started_at), + _ => phase.stats.elapsed(), + } } - pub fn record_construction_slot_assigned(&mut self) { - self.construction_slots_assigned += 1; + run_totals!( + /// Steps taken across all phases. + step_count, + /// Candidate moves actually yielded by cursors across all phases. + /// Unrequested logical neighborhood tails are not generated work. + moves_generated, + moves_evaluated, + /// Candidates evaluated ahead of the sequential decision point by + /// parallel evaluation and then discarded. + moves_speculative, + moves_accepted, + moves_applied, + moves_not_doable, + moves_acceptor_rejected, + moves_forager_ignored, + moves_hard_improving, + moves_hard_neutral, + moves_hard_worse, + conflict_repair_provider_generated, + conflict_repair_duplicate_filtered, + conflict_repair_illegal_filtered, + conflict_repair_not_doable_filtered, + conflict_repair_hard_improving, + conflict_repair_exposed, + score_calculations, + construction_slots_assigned, + construction_slots_kept, + construction_slots_no_doable, + ); + + /// Required scalar-assignment rows still unassigned, summed over groups. + pub fn scalar_assignment_required_remaining(&self) -> u64 { + self.scalar_assignment_required_remaining_by_group + .values() + .copied() + .sum() } - pub fn record_construction_slot_kept(&mut self) { - self.construction_slots_kept += 1; - } + record_into_active!( + /// Records one or more generated candidate moves and the time spent generating them. + record_generated_batch(count: u64, duration: Duration), + record_selector_generated(selector_index: usize, count: u64, duration: Duration), + /// Records generation time that did not itself yield a counted move. + record_generation_time(duration: Duration), + /// Records a single generated candidate move and the time spent generating it. + record_generated_move(duration: Duration), + /// Records a move evaluation and the time spent evaluating it. + record_evaluated_move(duration: Duration), + record_selector_evaluated(selector_index: usize, duration: Duration), + record_move_accepted(), + record_selector_accepted(selector_index: usize), + record_move_applied(), + record_selector_applied(selector_index: usize), + record_move_not_doable(), + record_selector_not_doable(selector_index: usize), + record_move_acceptor_rejected(), + record_selector_acceptor_rejected(selector_index: usize), + record_moves_forager_ignored(count: u64), + record_move_hard_improving(), + record_move_hard_neutral(), + record_move_hard_worse(), + record_conflict_repair_provider_generated(count: u64), + record_conflict_repair_duplicate_filtered(), + record_conflict_repair_illegal_filtered(), + record_conflict_repair_not_doable_filtered(), + record_conflict_repair_hard_improving(), + record_conflict_repair_exposed(), + /// Records a step completion. + record_step(), + /// Records a score calculation. + record_score_calculation(), + /// Records a candidate that parallel evaluation evaluated ahead of the + /// sequential decision point and then discarded. + record_move_speculative(), + record_construction_slot_assigned(), + record_construction_slot_kept(), + record_construction_slot_no_doable(), + record_move_kind_generated(move_label: &'static str), + record_move_kind_evaluated(move_label: &'static str, score_ordering: Ordering), + record_move_kind_evaluated_unscored(move_label: &'static str), + record_move_kind_accepted(move_label: &'static str), + record_move_kind_applied(move_label: &'static str, score_improvement: f64), + record_move_kind_not_doable(move_label: &'static str), + record_move_kind_acceptor_rejected(move_label: &'static str, score_ordering: Ordering), + record_move_kind_forager_ignored(move_label: &'static str, count: u64), + record_applied_move_trace(applied_move: AppliedMoveTelemetry), + ); - pub fn record_construction_slot_no_doable(&mut self) { - self.construction_slots_no_doable += 1; + pub fn record_selector_generated_with_label( + &mut self, + selector_index: usize, + selector_label: impl Into, + count: u64, + duration: Duration, + ) { + self.active().record_selector_generated_with_label( + selector_index, + selector_label, + count, + duration, + ); } + /// Records the required rows a scalar-assignment group still leaves + /// unassigned. The run value sums the latest count of every group; the + /// running phase keeps the latest count it saw. pub fn record_scalar_assignment_required_remaining( &mut self, group_name: &'static str, @@ -227,43 +297,47 @@ impl SolverStats { ) { self.scalar_assignment_required_remaining_by_group .insert(group_name, count); - self.scalar_assignment_required_remaining = self - .scalar_assignment_required_remaining_by_group - .values() - .copied() - .sum(); + self.active() + .record_scalar_assignment_required_remaining(count); + } + + pub fn generation_time(&self) -> Duration { + self.folded.generation_time() + + self + .phase() + .map_or(Duration::ZERO, PhaseStats::generation_time) + } + + pub fn evaluation_time(&self) -> Duration { + self.folded.evaluation_time() + + self + .phase() + .map_or(Duration::ZERO, PhaseStats::evaluation_time) } pub fn generated_throughput(&self) -> Throughput { Throughput { - count: self.moves_generated, - elapsed: self.generation_time, + count: self.moves_generated(), + elapsed: self.generation_time(), } } pub fn evaluated_throughput(&self) -> Throughput { Throughput { - count: self.moves_evaluated, - elapsed: self.evaluation_time, + count: self.moves_evaluated(), + elapsed: self.evaluation_time(), } } pub fn acceptance_rate(&self) -> f64 { - if self.moves_evaluated == 0 { + let evaluated = self.moves_evaluated(); + if evaluated == 0 { 0.0 } else { - self.moves_accepted as f64 / self.moves_evaluated as f64 + self.moves_accepted() as f64 / evaluated as f64 } } - pub fn generation_time(&self) -> Duration { - self.generation_time - } - - pub fn evaluation_time(&self) -> Duration { - self.evaluation_time - } - pub fn snapshot(&self) -> SolverTelemetry { self.snapshot_with_applied_move_trace(true) } @@ -276,39 +350,54 @@ impl SolverStats { &self, include_applied_move_trace: bool, ) -> SolverTelemetry { + let mut totals = self.folded.clone(); + let mut phases: Vec = self + .finished_phases + .iter() + .map(|phase| phase.stats.snapshot_with_elapsed(phase.elapsed)) + .collect(); + if let Some(phase) = &self.current_phase { + totals.absorb(&phase.stats); + phases.push( + phase + .stats + .snapshot_with_elapsed(self.running_elapsed(phase)), + ); + } SolverTelemetry { elapsed: self.elapsed(), - step_count: self.step_count, - moves_generated: self.moves_generated, - moves_evaluated: self.moves_evaluated, - moves_accepted: self.moves_accepted, - moves_applied: self.moves_applied, - moves_score_improving: self.moves_score_improving(), - moves_applied_improving: self.moves_applied_improving(), - moves_not_doable: self.moves_not_doable, - moves_acceptor_rejected: self.moves_acceptor_rejected, - moves_forager_ignored: self.moves_forager_ignored, - moves_hard_improving: self.moves_hard_improving, - moves_hard_neutral: self.moves_hard_neutral, - moves_hard_worse: self.moves_hard_worse, - conflict_repair_provider_generated: self.conflict_repair_provider_generated, - conflict_repair_duplicate_filtered: self.conflict_repair_duplicate_filtered, - conflict_repair_illegal_filtered: self.conflict_repair_illegal_filtered, - conflict_repair_not_doable_filtered: self.conflict_repair_not_doable_filtered, - conflict_repair_hard_improving: self.conflict_repair_hard_improving, - conflict_repair_exposed: self.conflict_repair_exposed, - score_calculations: self.score_calculations, - construction_slots_assigned: self.construction_slots_assigned, - construction_slots_kept: self.construction_slots_kept, - construction_slots_no_doable: self.construction_slots_no_doable, - scalar_assignment_required_remaining: self.scalar_assignment_required_remaining, - generation_time: self.generation_time, - evaluation_time: self.evaluation_time, - phase: None, - selector_telemetry: self.selector_stats.clone(), - move_telemetry: self.move_stats.values().cloned().collect(), + step_count: totals.step_count, + moves_generated: totals.moves_generated, + moves_evaluated: totals.moves_evaluated, + moves_speculative: totals.moves_speculative, + moves_accepted: totals.moves_accepted, + moves_applied: totals.moves_applied, + moves_score_improving: totals.moves_score_improving(), + moves_applied_improving: totals.moves_applied_improving(), + moves_not_doable: totals.moves_not_doable, + moves_acceptor_rejected: totals.moves_acceptor_rejected, + moves_forager_ignored: totals.moves_forager_ignored, + moves_hard_improving: totals.moves_hard_improving, + moves_hard_neutral: totals.moves_hard_neutral, + moves_hard_worse: totals.moves_hard_worse, + conflict_repair_provider_generated: totals.conflict_repair_provider_generated, + conflict_repair_duplicate_filtered: totals.conflict_repair_duplicate_filtered, + conflict_repair_illegal_filtered: totals.conflict_repair_illegal_filtered, + conflict_repair_not_doable_filtered: totals.conflict_repair_not_doable_filtered, + conflict_repair_hard_improving: totals.conflict_repair_hard_improving, + conflict_repair_exposed: totals.conflict_repair_exposed, + score_calculations: totals.score_calculations, + construction_slots_assigned: totals.construction_slots_assigned, + construction_slots_kept: totals.construction_slots_kept, + construction_slots_no_doable: totals.construction_slots_no_doable, + scalar_assignment_required_remaining: self.scalar_assignment_required_remaining(), + generation_time: totals.generation_time(), + evaluation_time: totals.evaluation_time(), + phases, + selector_telemetry: totals.selector_telemetry().to_vec(), + move_telemetry: totals.move_telemetry(), applied_move_trace: if include_applied_move_trace { - self.applied_move_trace.to_vec() + totals.applied_move_trace().to_vec() } else { Vec::new() }, @@ -322,82 +411,14 @@ impl SolverStats { } } - pub fn record_move_kind_generated(&mut self, move_label: &'static str) { - self.move_stats_entry(move_label).moves_generated += 1; - } - pub fn moves_score_improving(&self) -> u64 { - self.move_stats - .values() - .map(|entry| entry.moves_score_improving) - .sum() + self.folded.moves_score_improving() + + self.phase().map_or(0, PhaseStats::moves_score_improving) } pub fn moves_applied_improving(&self) -> u64 { - self.move_stats - .values() - .map(|entry| entry.moves_applied_improving) - .sum() - } - - pub fn record_move_kind_evaluated( - &mut self, - move_label: &'static str, - score_ordering: Ordering, - ) { - let entry = self.move_stats_entry(move_label); - entry.moves_evaluated += 1; - match score_ordering { - Ordering::Greater => entry.moves_score_improving += 1, - Ordering::Equal => entry.moves_score_equal += 1, - Ordering::Less => entry.moves_score_worse += 1, - } - } - - pub fn record_move_kind_evaluated_unscored(&mut self, move_label: &'static str) { - self.move_stats_entry(move_label).moves_evaluated += 1; - } - - pub fn record_move_kind_accepted(&mut self, move_label: &'static str) { - self.move_stats_entry(move_label).moves_accepted += 1; - } - - pub fn record_move_kind_applied(&mut self, move_label: &'static str, score_improvement: f64) { - let entry = self.move_stats_entry(move_label); - entry.moves_applied += 1; - if score_improvement > 0.0 { - entry.moves_applied_improving += 1; - entry.applied_score_improvement += score_improvement; - } - } - - pub fn record_move_kind_not_doable(&mut self, move_label: &'static str) { - self.move_stats_entry(move_label).moves_not_doable += 1; - } - - pub fn record_move_kind_acceptor_rejected( - &mut self, - move_label: &'static str, - score_ordering: Ordering, - ) { - let entry = self.move_stats_entry(move_label); - entry.moves_acceptor_rejected += 1; - if score_ordering == Ordering::Greater { - entry.moves_rejected_improving += 1; - } - } - - pub fn record_move_kind_forager_ignored(&mut self, move_label: &'static str, count: u64) { - if count == 0 { - return; - } - self.move_stats_entry(move_label).moves_forager_ignored += count; - } - - pub fn record_applied_move_trace(&mut self, applied_move: AppliedMoveTelemetry) { - if self.applied_move_trace.len() < APPLIED_MOVE_TRACE_LIMIT { - self.applied_move_trace.push(applied_move); - } + self.folded.moves_applied_improving() + + self.phase().map_or(0, PhaseStats::moves_applied_improving) } pub(crate) fn enable_candidate_trace(&mut self, candidate_trace: CandidateTraceTelemetry) { @@ -457,62 +478,6 @@ impl SolverStats { .expect("a candidate trace disposition requires an enabled trace"); trace.record_disposition(token, disposition); } - - pub fn record_selector_generated_with_label( - &mut self, - selector_index: usize, - selector_label: impl Into, - count: u64, - duration: Duration, - ) { - self.record_generated_batch(count, duration); - let selector = self.selector_stats_entry_with_label(selector_index, selector_label); - selector.moves_generated += count; - selector.generation_time += duration; - } - - fn selector_stats_entry(&mut self, selector_index: usize) -> &mut SelectorTelemetry { - self.selector_stats_entry_with_label(selector_index, format!("selector-{selector_index}")) - } - - fn selector_stats_entry_with_label( - &mut self, - selector_index: usize, - selector_label: impl Into, - ) -> &mut SelectorTelemetry { - let selector_label = selector_label.into(); - if let Some(position) = self - .selector_stats - .iter() - .position(|entry| entry.selector_index == selector_index) - { - if self.selector_stats[position] - .selector_label - .starts_with("selector-") - && !selector_label.starts_with("selector-") - { - self.selector_stats[position].selector_label = selector_label; - } - return &mut self.selector_stats[position]; - } - self.selector_stats.push(SelectorTelemetry { - selector_index, - selector_label, - ..SelectorTelemetry::default() - }); - self.selector_stats - .last_mut() - .expect("selector stats entry was just inserted") - } - - fn move_stats_entry(&mut self, move_label: &'static str) -> &mut MoveTelemetry { - self.move_stats - .entry(move_label) - .or_insert_with(|| MoveTelemetry { - move_label: move_label.to_string(), - ..MoveTelemetry::default() - }) - } } /* Phase-level statistics. diff --git a/crates/solverforge-solver/src/stats/telemetry.rs b/crates/solverforge-solver/src/stats/telemetry.rs index 85874d145..d050e1fff 100644 --- a/crates/solverforge-solver/src/stats/telemetry.rs +++ b/crates/solverforge-solver/src/stats/telemetry.rs @@ -19,16 +19,19 @@ use std::time::Duration; let mut stats = SolverStats::default(); stats.start(); +stats.begin_phase("LocalSearch"); stats.record_step(); stats.record_generated_move(Duration::from_millis(1)); stats.record_evaluated_move(Duration::from_millis(2)); stats.record_move_accepted(); stats.record_generated_move(Duration::from_millis(1)); stats.record_evaluated_move(Duration::from_millis(2)); +stats.end_phase(); -assert_eq!(stats.step_count, 1); -assert_eq!(stats.moves_evaluated, 2); -assert_eq!(stats.moves_accepted, 1); +assert_eq!(stats.step_count(), 1); +assert_eq!(stats.moves_evaluated(), 2); +assert_eq!(stats.moves_accepted(), 1); +assert_eq!(stats.snapshot().phases[0].moves_evaluated, 2); ``` */ #[derive(Debug, Clone, Default, PartialEq)] @@ -79,6 +82,7 @@ pub struct MoveTelemetry { #[derive(Debug, Clone, Default, PartialEq)] pub struct PhaseTelemetry { + /// Zero-based position of this phase in the solve's execution order. pub phase_index: usize, pub phase_type: String, pub elapsed: Duration, @@ -87,6 +91,9 @@ pub struct PhaseTelemetry { /// This is runtime work, not the size of an unconsumed neighborhood. pub moves_generated: u64, pub moves_evaluated: u64, + /// Candidates this phase's parallel evaluation evaluated ahead of the + /// sequential decision point and then discarded. + pub moves_speculative: u64, pub moves_accepted: u64, pub moves_applied: u64, pub moves_score_improving: u64, @@ -120,6 +127,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, @@ -143,7 +153,11 @@ pub struct SolverTelemetry { pub scalar_assignment_required_remaining: u64, pub generation_time: Duration, pub evaluation_time: Duration, - pub phase: Option, + /// Every phase started so far, in execution order; the last entry of a + /// progress snapshot is the phase still running. Run totals above equal + /// the sum of these phases plus work done outside any phase, such as the + /// initial score calculation. + pub phases: Vec, pub selector_telemetry: Vec, pub move_telemetry: Vec, pub applied_move_trace: Vec, @@ -159,6 +173,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, @@ -182,7 +197,7 @@ impl SolverTelemetry { scalar_assignment_required_remaining: 0, generation_time: Duration::ZERO, evaluation_time: Duration::ZERO, - phase: None, + phases: Vec::new(), selector_telemetry: Vec::new(), move_telemetry: Vec::new(), applied_move_trace: Vec::new(), diff --git a/crates/solverforge-solver/src/stats/tests.rs b/crates/solverforge-solver/src/stats/tests.rs index 2a06a8409..de968baf1 100644 --- a/crates/solverforge-solver/src/stats/tests.rs +++ b/crates/solverforge-solver/src/stats/tests.rs @@ -22,7 +22,78 @@ fn solver_snapshot_preserves_exact_counts_and_durations() { assert_eq!(snapshot.score_calculations, 1); assert_eq!(snapshot.generation_time, Duration::from_millis(4)); assert_eq!(snapshot.evaluation_time, Duration::from_millis(5)); - assert!(snapshot.phase.is_none()); + assert!(snapshot.phases.is_empty()); +} + +#[test] +fn phase_records_sum_to_the_run_totals() { + let mut stats = SolverStats::default(); + stats.start(); + stats.record_score_calculation(); + + stats.begin_phase("Construction Heuristic"); + stats.record_step(); + stats.record_generated_batch(4, Duration::from_millis(2)); + stats.record_evaluated_move(Duration::from_millis(3)); + stats.record_score_calculation(); + stats.record_construction_slot_assigned(); + stats.end_phase(); + + stats.begin_phase("Local Search"); + stats.record_step(); + stats.record_step(); + stats.record_selector_generated_with_label(0, "change", 5, Duration::from_millis(1)); + stats.record_selector_evaluated(0, Duration::from_millis(7)); + stats.record_selector_evaluated(0, Duration::from_millis(7)); + stats.record_move_speculative(); + stats.record_move_accepted(); + stats.record_move_applied(); + stats.record_move_kind_applied("change", 1.0); + + let running = stats.snapshot(); + assert_eq!(running.phases.len(), 2); + assert_eq!(running.phases[1].phase_type, "Local Search"); + assert_eq!(running.phases[1].moves_speculative, 1); + stats.end_phase(); + let done = stats.snapshot(); + + let types: Vec<_> = done + .phases + .iter() + .map(|phase| phase.phase_type.as_str()) + .collect(); + assert_eq!(types, ["Construction Heuristic", "Local Search"]); + assert_eq!( + done.phases + .iter() + .map(|phase| phase.phase_index) + .collect::>(), + [0, 1] + ); + let sum = |field: fn(&PhaseTelemetry) -> u64| done.phases.iter().map(field).sum::(); + assert_eq!(sum(|phase| phase.step_count), done.step_count); + assert_eq!(sum(|phase| phase.moves_generated), done.moves_generated); + assert_eq!(sum(|phase| phase.moves_evaluated), done.moves_evaluated); + assert_eq!(sum(|phase| phase.moves_speculative), done.moves_speculative); + assert_eq!(sum(|phase| phase.moves_accepted), done.moves_accepted); + assert_eq!(sum(|phase| phase.moves_applied), done.moves_applied); + assert_eq!( + sum(|phase| phase.moves_applied_improving), + done.moves_applied_improving + ); + // The initial score calculation belongs to no phase. + assert_eq!( + sum(|phase| phase.score_calculations) + 1, + done.score_calculations + ); + let time = + |field: fn(&PhaseTelemetry) -> Duration| done.phases.iter().map(field).sum::(); + assert_eq!(time(|phase| phase.generation_time), done.generation_time); + assert_eq!(time(|phase| phase.evaluation_time), done.evaluation_time); + assert_eq!(done.construction_slots_assigned, 1); + assert_eq!(done.selector_telemetry[0].moves_evaluated, 2); + assert_eq!(stats.moves_evaluated(), 3); + assert!(stats.phase().is_none()); } #[test] @@ -178,11 +249,11 @@ fn solver_scalar_assignment_remaining_aggregates_by_group() { stats.record_scalar_assignment_required_remaining("group_a", 3); stats.record_scalar_assignment_required_remaining("group_b", 0); - assert_eq!(stats.scalar_assignment_required_remaining, 3); + assert_eq!(stats.scalar_assignment_required_remaining(), 3); assert_eq!(stats.snapshot().scalar_assignment_required_remaining, 3); stats.record_scalar_assignment_required_remaining("group_a", 0); - assert_eq!(stats.scalar_assignment_required_remaining, 0); + assert_eq!(stats.scalar_assignment_required_remaining(), 0); assert_eq!(stats.snapshot().scalar_assignment_required_remaining, 0); } @@ -355,3 +426,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); +} diff --git a/crates/solverforge-solver/src/termination/move_count.rs b/crates/solverforge-solver/src/termination/move_count.rs index 69ec6faae..917de20fc 100644 --- a/crates/solverforge-solver/src/termination/move_count.rs +++ b/crates/solverforge-solver/src/termination/move_count.rs @@ -62,7 +62,7 @@ impl, BestCb: ProgressCallback> Terminati for MoveCountTermination { fn is_terminated(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>) -> bool { - solver_scope.stats().moves_evaluated >= self.limit + solver_scope.stats().moves_evaluated() >= self.limit } fn install_inphase_limits(&self, solver_scope: &mut SolverScope) { diff --git a/crates/solverforge-solver/src/termination/score_calculation_count.rs b/crates/solverforge-solver/src/termination/score_calculation_count.rs index 0d10506e7..c221ffcfe 100644 --- a/crates/solverforge-solver/src/termination/score_calculation_count.rs +++ b/crates/solverforge-solver/src/termination/score_calculation_count.rs @@ -62,7 +62,7 @@ impl, BestCb: ProgressCallback> Terminati for ScoreCalculationCountTermination { fn is_terminated(&mut self, solver_scope: &SolverScope<'_, S, D, BestCb>) -> bool { - solver_scope.stats().score_calculations >= self.limit + solver_scope.stats().score_calculations() >= self.limit } fn install_inphase_limits(&self, solver_scope: &mut SolverScope) { diff --git a/crates/solverforge-solver/src/test_utils.rs b/crates/solverforge-solver/src/test_utils.rs index 8096a75d9..86eaa8fc6 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 000000000..bbfbc8e73 --- /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) +} 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 000000000..aead2513e --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search.rs @@ -0,0 +1,174 @@ +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); + } +} + +#[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/mod.rs b/crates/solverforge/tests/parallel_list_local_search/domain/mod.rs new file mode 100644 index 000000000..4c39c4e10 --- /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 000000000..c21d761b0 --- /dev/null +++ b/crates/solverforge/tests/parallel_list_local_search/domain/plan.rs @@ -0,0 +1,84 @@ +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, +} + +// 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::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::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() + .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 000000000..5f917b5ee --- /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 000000000..eaf35445b --- /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, +} diff --git a/crates/solverforge/tests/parallel_local_search.rs b/crates/solverforge/tests/parallel_local_search.rs new file mode 100644 index 000000000..f488bfc6c --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search.rs @@ -0,0 +1,141 @@ +use solverforge::{ + CandidateTraceDisposition, 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, + } +} + +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"); + 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 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"); + Run { + plan, + telemetry, + consumed_pulls, + } +} + +#[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 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 Run { + plan: solution, + telemetry, + consumed_pulls, + } in &runs[1..] + { + assert_eq!(consumed_pulls, baseline_pulls); + 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 run = solve(&MANAGER, move_threads); + assert_eq!(run.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 000000000..7151628ca --- /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 000000000..7cfdc56d8 --- /dev/null +++ b/crates/solverforge/tests/parallel_local_search/domain/plan.rs @@ -0,0 +1,65 @@ +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 + +[candidate_trace] +max_entries = 1_000_000 + +[[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 000000000..9885d81b1 --- /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 000000000..d7d81e016 --- /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, +} diff --git a/docs/architecture.html b/docs/architecture.html index 853a09b69..746b3b903 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 @@ +