Skip to content
Closed
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
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ composition.
Clone is acceptable only for:

1. Solution snapshots sent through channels.
2. Partitioned search creating partition copies.
2. Parallel search creating worker-private solution copies: partitioned
search's partition copies, and parallel local-search evaluation's
evaluator replicas — one per evaluator beyond the main director, made at
phase start and again after the working solution is written outside the
parallel loop's own commits.
3. Explicit user-owned result snapshots outside retained scoring state, such as
`CollectedVec::to_vec()` when the caller deliberately asks for an owned
`Vec<T>`.
Expand Down Expand Up @@ -276,6 +280,9 @@ Use `rayon` for CPU-bound work and `tokio` for async coordination.
- Do not add `std::thread::spawn`.
- Do not add `std::thread::sleep`.
- Prefer ownership transfer over callback APIs that only expose `&Self`.
- Parallel local-search evaluation runs one `rayon::scope` per evaluation
window on the solve thread's current pool; do not add dedicated pools or
blocking waits inside evaluation workers.

### Configured Completion

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
4 changes: 3 additions & 1 deletion crates/solverforge-scoring/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,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; default: `None` |

### `DirectorScoreState<Sc>`

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

**`ScoreDirector<S, C>`** where `S: PlanningSolution`, `C: ConstraintSet<S, S::Score>`
- Primary incremental scoring director. Zero-erasure.
- Public methods: `new()`, `with_descriptor()`, `simple()` (convenience for
- Public methods: `new()`, `with_descriptor()`, `with_replica_constraints()`, `simple()` (convenience for
`ScoreDirector<S, ()>`), `simple_zero()` (test helper with empty descriptor),
`working_solution()`, `working_solution_mut()`, `into_working_solution()`,
`calculate_score()`, `before_variable_changed()`,
Expand All @@ -515,6 +516,7 @@ Stock collectors include `count()`, `sum()`, `load_balance()`,
- Returns borrowed constraint metadata views from the monomorphized `ConstraintSet` on demand.
- `simple(solution, descriptor, entity_counter)` — creates `ScoreDirector<S, ()>` with empty constraint set
- `simple_zero(solution)` — creates `ScoreDirector<S, ()>` with empty descriptor and zero entity counter
- `with_replica_constraints(constraints: fn(&S) -> C)` — records the constraint-set constructor used by `Director::fork_replica`; a replica clones the working solution, builds its constraint set from that clone, and initializes it. Directors without a constructor do not fork.
- Implements `Director<S>`

### Constraint Types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ where
.field("initialized", &self.is_initialized())
.field("cached_score", &self.get_score())
.field("constraint_count", &self.constraint_count())
.field("forks_replicas", &self.replica_constraints.is_some())
.finish()
}
}
Expand Down Expand Up @@ -91,4 +92,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
Expand Up @@ -78,6 +78,11 @@ where
throughout the solver pipeline.
*/
pub(super) entity_counter: fn(&S, usize) -> usize,
/* Constraint-set constructor used to build evaluator replicas.

`None` means this director cannot fork replicas.
*/
pub(super) replica_constraints: Option<fn(&S) -> C>,
_phantom: PhantomData<fn() -> S>,
}

Expand Down Expand Up @@ -129,10 +134,40 @@ where
initialized: false,
solution_descriptor,
entity_counter,
replica_constraints: None,
_phantom: PhantomData,
}
}

/* Records the constructor that builds each evaluator replica's constraint
set. The constructor receives the replica's own working solution.
*/
pub fn with_replica_constraints(mut self, constraints: fn(&S) -> C) -> Self {
self.replica_constraints = Some(constraints);
self
}

/* Builds an initialized evaluator replica from a clone of the working
solution and a constraint set built for that clone.

The working-solution clone is the evaluator replica clone boundary: it
happens when replicas are created, never per candidate or per step.
*/
pub(crate) fn fork_replica_impl(&self) -> Option<Self> {
let constraints = self.replica_constraints?;
let solution = self.working_solution.clone();
let replica_constraints = constraints(&solution);
let mut replica = Self::with_descriptor(
solution,
replica_constraints,
self.solution_descriptor.clone(),
self.entity_counter,
);
replica.replica_constraints = Some(constraints);
replica.calculate_score_impl();
Some(replica)
}

/* =========================================================================
Private implementation methods (shared between inherent and trait impl)
=========================================================================
Expand Down
62 changes: 62 additions & 0 deletions crates/solverforge-scoring/src/director/tests/score_director.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,65 @@ fn test_add_then_remove_value() {
director.do_change(0, 0, |s| s.values[0] = None);
assert_eq!(director.get_score(), SoftScore::of(-2));
}

fn unassigned_constraints(
_solution: &TestSolution,
) -> (impl IncrementalConstraint<TestSolution, SoftScore>,) {
(make_unassigned_constraint(),)
}

#[test]
fn fork_replica_requires_replica_constraints() {
let solution = TestSolution {
values: vec![Some(1), None],
score: None,
};
let mut director = ScoreDirector::new(solution, (make_unassigned_constraint(),));
director.calculate_score();

assert!(director.fork_replica().is_none());
}

#[test]
fn fork_replica_matches_score_and_stays_independent() {
let solution = TestSolution {
values: vec![Some(1), None, None],
score: None,
};
let mut director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution))
.with_replica_constraints(unassigned_constraints);
let main_score = director.calculate_score();

let mut replica = director
.fork_replica()
.expect("director with a constraint constructor must fork");
assert!(replica.is_initialized());
assert_eq!(replica.get_score(), main_score);

replica.before_variable_changed(0, 1);
replica.working_solution_mut().values[1] = Some(7);
replica.after_variable_changed(0, 1);

assert_eq!(replica.get_score(), SoftScore::of(-1));
assert_eq!(director.get_score(), SoftScore::of(-2));
assert_eq!(director.working_solution().values[1], None);
}

#[test]
fn fork_of_uninitialized_director_initializes_the_replica() {
let solution = TestSolution {
values: vec![None, Some(3), None, None],
score: None,
};
let director = ScoreDirector::new(solution.clone(), unassigned_constraints(&solution))
.with_replica_constraints(unassigned_constraints);
assert!(!director.is_initialized());

let replica = director
.fork_replica()
.expect("director with a constraint constructor must fork");

assert!(replica.is_initialized());
assert_eq!(replica.get_score(), SoftScore::of(-3));
assert!(!director.is_initialized());
}
12 changes: 12 additions & 0 deletions crates/solverforge-scoring/src/director/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,16 @@ pub trait Director<S: PlanningSolution>: 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<Self>
where
Self: Sized,
{
None
}
}
Loading
Loading