Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`.
Expand Down Expand Up @@ -276,6 +281,9 @@ Use `rayon` for CPU-bound work and `tokio` for async coordination.
- Do not add `std::thread::spawn`.
- Do not add `std::thread::sleep`.
- Prefer ownership transfer over callback APIs that only expose `&Self`.
- Parallel local-search evaluation runs one `rayon::scope` per evaluation
window on the solve thread's current pool; do not add dedicated pools or
blocking waits inside evaluation workers.

### Configured Completion

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,13 @@ SolverForge leverages Rust's zero-cost abstractions:
- **Cache-friendly**: Contiguous memory layouts for hot paths
- **Concrete native execution**: Score directors, deciders, bounders, move carriers, and selector carriers remain monomorphized; documented scorer-agnostic `&dyn Director` callbacks are narrow intentional seams

Local search can evaluate candidates on several cores within one solve:
`move_thread_count = { count = 4 }` in `solver.toml` (Python configs through
solverforge-py use `{"move_thread_count": {"count": 4}}`) runs each step's
trials on up to four evaluators while one coordinator keeps the same
acceptance order. Results are reproducible at fixed work, and the default
(`auto`) currently stays sequential.

Absolute throughput depends on the model, constraints, neighborhood, hardware,
and termination policy. Use emitted generated/evaluated throughput for one run
and the controlled paired benchmark gate below for regression decisions.
Expand Down
1 change: 1 addition & 0 deletions assets/solverforge-architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion crates/solverforge-bridge/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,19 @@ presentation and host-boundary conversion. `scoped_dynamic_score_family(family,
callback)` sets the thread-local family used by `DynamicScore::zero()` and
`DynamicScore::from_level_numbers(...)` during the callback.

The family is per thread. The solve thread enters it for the whole solve, but
parallel local-search evaluation scores evaluator replicas on Rayon worker
threads, so a binding's constraint set that builds `DynamicScore` values must
also enter its declared family inside its own `ConstraintSet` methods.

## Public Functions

### Compiled dynamic runner entrypoints

```rust
pub fn try_run_dynamic_solver_with_config_parts<S, C, V, DM, IDM>(
solution: S,
constraints: C,
constraints: fn(&S) -> C,
descriptor: SolutionDescriptor,
entity_count_by_descriptor: fn(&S, usize) -> usize,
runtime: SolverRuntime<S>,
Expand All @@ -207,6 +212,10 @@ where
This is the binding-oriented entrypoint. It consumes frozen dynamic model parts
into the same compiled graph and phase runner used by native models. It accepts
no phase builder, selector registration, or alternate construction path.
`constraints` builds a constraint set for a given solution; the runtime calls it
for the working solution and for every evaluator replica, so
`move_thread_count` evaluates dynamic models in parallel. Bindings rebuild
their host constraint set from the solution they receive.
The optional qualified provenance changes only the candidate-trace header; it
does not select another runner.

Expand Down
6 changes: 5 additions & 1 deletion crates/solverforge-bridge/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S, C, V, DM, IDM>(
solution: S,
constraints: C,
constraints: fn(&S) -> C,
descriptor: SolutionDescriptor,
entity_count_by_descriptor: fn(&S, usize) -> usize,
runtime: SolverRuntime<S>,
Expand Down
5 changes: 5 additions & 0 deletions crates/solverforge-bridge/src/score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ thread_local! {
/// This keeps the internal static three-level score contract available to the
/// solver while allowing binding-driven solves to present zero and derived
/// scores with the host model's declared score family.
///
/// The family is per thread. The solve thread enters it for the whole solve,
/// but parallel evaluation scores evaluator replicas on Rayon worker threads,
/// so a constraint set that builds `DynamicScore` values must also enter its
/// declared family inside its own `ConstraintSet` methods.
pub fn scoped_dynamic_score_family<T>(
family: DynamicScoreFamily,
callback: impl FnOnce() -> T,
Expand Down
13 changes: 13 additions & 0 deletions crates/solverforge-config/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,19 @@ Derives: `Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize`.
| `None` | Single-threaded |
| `Count(usize)` | Explicit count |

TOML: `move_thread_count = "auto"`, `"none"`, or `{ count = 4 }`.

`SolverConfig::move_thread_count` drives parallel evaluation in the compiled
runtime's acceptor-forager local search; `PartitionedSearchConfig::thread_count`
sizes partitioned search separately.

| `move_thread_count` | Acceptor-forager local search |
|---|---|
| `None` | sequential |
| `Auto` | sequential |
| `Count(0)`, `Count(1)` | sequential |
| `Count(n >= 2)` | `min(n, rayon::current_num_threads())` evaluators: the main director plus that many minus one replicas |

### `PhaseConfig`

Derives: `Debug, Clone, Deserialize, Serialize`. Tagged `#[serde(tag = "type", rename_all = "snake_case")]`.
Expand Down
17 changes: 17 additions & 0 deletions crates/solverforge-config/src/tests/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
2 changes: 1 addition & 1 deletion crates/solverforge-macros/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ semantics should omit `domain = "cvrp"` and declare explicit hook paths instead.
- `impl SolvableSolution for T` — delegates to `descriptor()` and `entity_count()`
- `impl Solvable for T` (when constraints path specified) — `solve(self, runtime: SolverRuntime<Self>, qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>)` 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<Self>, qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>)` (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<Self>, qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>)` (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<fn(&Plan) -> &[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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/solverforge-macros/src/planning_solution_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
17 changes: 14 additions & 3 deletions crates/solverforge-scoring/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ src/
│ ├── mod.rs — Re-exports all director types and traits
│ ├── traits.rs — Director<S> trait
│ ├── score_director.rs — Re-exports ScoreDirector pieces
│ │ ├── score_director/incremental.rs — ScoreDirector<S,C> (zero-erasure incremental)
│ │ ├── score_director/incremental.rs — ScoreDirector<S,C,Desc> (zero-erasure incremental)
│ │ ├── score_director/descriptor.rs — DirectorDescriptor, ReplicaDescriptor
│ │ └── score_director/adapters.rs — Debug and Director trait impls for ScoreDirector
│ ├── shadow_aware.rs — SolvableSolution trait and shadow lifecycle notes
│ └── tests/
Expand Down Expand Up @@ -333,7 +334,7 @@ pub use api::node_sharing::{SharedNodeDiagnostics, SharedNodeId, SharedNodeOpera
pub use api::weight_overrides::{ConstraintWeightOverrides, WeightProvider};

// Score Directors
pub use director::score_director::ScoreDirector;
pub use director::score_director::{DirectorDescriptor, ReplicaDescriptor, ScoreDirector};
pub use director::{Director, DirectorScoreState, SolvableSolution};

// Analysis
Expand Down Expand Up @@ -376,6 +377,7 @@ pub use stream::{
| `snapshot_score_state` | `fn snapshot_score_state(&self) -> DirectorScoreState<S::Score>` | Snapshot committed score state for speculative evaluation |
| `restore_score_state` | `fn restore_score_state(&mut self, state: DirectorScoreState<S::Score>)` | Restore a previously snapshotted committed score state |
| `reset` | `fn reset(&mut self)` | Default: no-op |
| `fork_replica` | `fn fork_replica(&self) -> Option<Self> where Self: Sized` | Independent evaluator replica with initialized incremental state, sharing the director's borrowed descriptor; default: `None` |

### `DirectorScoreState<Sc>`

Expand Down Expand Up @@ -502,7 +504,7 @@ Stock collectors include `count()`, `sum()`, `load_balance()`,

### Score Directors

**`ScoreDirector<S, C>`** where `S: PlanningSolution`, `C: ConstraintSet<S, S::Score>`
**`ScoreDirector<S, C, Desc = SolutionDescriptor>`** where `S: PlanningSolution`, `C: ConstraintSet<S, S::Score>`, `Desc: DirectorDescriptor<S, C>`
- Primary incremental scoring director. Zero-erasure.
- Public methods: `new()`, `with_descriptor()`, `simple()` (convenience for
`ScoreDirector<S, ()>`), `simple_zero()` (test helper with empty descriptor),
Expand All @@ -515,8 +517,17 @@ Stock collectors include `count()`, `sum()`, `load_balance()`,
- Returns borrowed constraint metadata views from the monomorphized `ConstraintSet` on demand.
- `simple(solution, descriptor, entity_counter)` — creates `ScoreDirector<S, ()>` with empty constraint set
- `simple_zero(solution)` — creates `ScoreDirector<S, ()>` 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<S>`

**`DirectorDescriptor<S, C>`** (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<S, Sc>`.
Expand Down
2 changes: 2 additions & 0 deletions crates/solverforge-scoring/src/director/score_director.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ constraint sets instead of trait-object-based scoring.
*/

mod adapters;
mod descriptor;
mod incremental;

pub use descriptor::{DirectorDescriptor, ReplicaDescriptor};
pub use incremental::ScoreDirector;
23 changes: 19 additions & 4 deletions crates/solverforge-scoring/src/director/score_director/adapters.rs
Original file line number Diff line number Diff line change
@@ -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<S, C> std::fmt::Debug for ScoreDirector<S, C>
impl<S, C, Desc> std::fmt::Debug for ScoreDirector<S, C, Desc>
where
S: PlanningSolution + std::fmt::Debug,
S::Score: std::fmt::Debug,
C: ConstraintSet<S, S::Score>,
Desc: DirectorDescriptor<S, C>,
{
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<S, C> Director<S> for ScoreDirector<S, C>
impl<S, C, Desc> Director<S> for ScoreDirector<S, C, Desc>
where
S: PlanningSolution,
S::Score: Score,
C: ConstraintSet<S, S::Score> + Send,
Desc: DirectorDescriptor<S, C> + Send,
{
fn working_solution(&self) -> &S {
self.working_solution()
Expand All @@ -43,7 +50,7 @@ where
}

fn solution_descriptor(&self) -> &SolutionDescriptor {
&self.solution_descriptor
self.solution_descriptor.solution_descriptor()
}

fn clone_working_solution(&self) -> S {
Expand All @@ -66,7 +73,11 @@ where
}

fn total_entity_count(&self) -> Option<usize> {
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)
Expand All @@ -91,4 +102,8 @@ where
fn reset(&mut self) {
self.reset_impl();
}

fn fork_replica(&self) -> Option<Self> {
self.fork_replica_impl()
}
}
Original file line number Diff line number Diff line change
@@ -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<S, C> {
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<S, C> DirectorDescriptor<S, C> 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<fn() -> (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<S, C> Clone for ReplicaDescriptor<'_, S, C> {
fn clone(&self) -> Self {
*self
}
}

impl<S, C> Copy for ReplicaDescriptor<'_, S, C> {}

impl<S, C> DirectorDescriptor<S, C> for ReplicaDescriptor<'_, S, C> {
#[inline]
fn solution_descriptor(&self) -> &SolutionDescriptor {
self.descriptor
}

fn replica(&self) -> Option<(Self, fn(&S) -> C)> {
Some((*self, self.constraints))
}
}
Loading
Loading