Skip to content
Merged
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
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ members = [
"crates/solverforge",
"crates/solverforge-cvrp",
"crates/solverforge-test",
"examples/scalar-graph-coloring",
"examples/list-tsp",
"examples/mixed-job-shop",
"examples/nqueens",
]

[workspace.package]
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ examples: banner
@printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
@printf "$(CYAN)$(BOLD)║ Building Examples ║$(RESET)\n"
@printf "$(CYAN)$(BOLD)╚══════════════════════════════════════╝$(RESET)\n\n"
@for ex in nqueens employee-scheduling vehicle-routing; do \
@for ex in scalar-graph-coloring list-tsp mixed-job-shop nqueens; do \
printf "$(PROGRESS) Building $$ex...\n"; \
cargo build -p $$ex --quiet && \
printf "$(GREEN)$(CHECK) Built $$ex$(RESET)\n" || \
Expand Down
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Current public naming follows neutral Rust contracts rather than `Typed*` prefix
## Features

- **Score Types**: SoftScore, HardSoftScore, HardMediumSoftScore, BendableScore, HardSoftDecimalScore
- **ConstraintStream API**: Declarative constraints with fluent builders, source-aware generated streams, single-source and cross-join projected scoring rows, existence checks, joins, grouping, and balance/complemented streams
- **ConstraintStream API**: Declarative constraints with fluent builders, model-owned collection sources, single-source and cross-join projected scoring rows, existence checks, joins, grouping, and balance/complemented streams
- **SERIO Engine**: Scoring Engine for Real-time Incremental Optimization
- **Solver Phases**:
- Generic Construction Heuristics (`FirstFit`, `CheapestInsertion`) over one mixed scalar/list `ModelContext` when matching list work is present, plus descriptor-scalar construction routing for pure scalar targets and specialized list phases (`ListRoundRobin`, `ListCheapestInsertion`, `ListRegretInsertion`, `ListClarkeWright`, `ListKOpt`)
Expand Down Expand Up @@ -226,18 +226,29 @@ order; they do not consume construction order keys.

### 2. Define Constraints

The `#[planning_solution]` macro generates a `ScheduleConstraintStreams` trait with typed accessors for each collection field, so `factory.shifts()` replaces manual `for_each` extractors:
The `#[planning_solution]` macro generates source functions on the solution type
for each collection field. Use those functions with `ConstraintFactory::for_each(...)`
so constraints stay tied to the model-owned field instead of to a generated trait
import:

```rust
use solverforge::{ConstraintSet, HardSoftScore};
use crate::domain::ScheduleConstraintStreams; // generated by #[planning_solution]
use solverforge::prelude::*;
use solverforge::stream::{joiner::*, ConstraintFactory};

use crate::domain::{Employee, Schedule, Shift};

fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
let unassigned = ConstraintFactory::<Schedule, HardSoftScore>::new()
.for_each(Schedule::shifts())
.unassigned()
.penalize_hard()
.named("Unassigned shift");

let required_skill = ConstraintFactory::<Schedule, HardSoftScore>::new()
.shifts()
.for_each(Schedule::shifts())
.join((
ConstraintFactory::<Schedule, HardSoftScore>::new().employees(),
ConstraintFactory::<Schedule, HardSoftScore>::new()
.for_each(Schedule::employees()),
equal_bi(
|shift: &Shift| shift.employee,
|emp: &Employee| Some(emp.id),
Expand All @@ -250,15 +261,15 @@ fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
.named("Required skill");

let no_overlap = ConstraintFactory::<Schedule, HardSoftScore>::new()
.shifts()
.for_each(Schedule::shifts())
.join(equal(|shift: &Shift| shift.employee))
.filter(|a: &Shift, b: &Shift| {
a.employee.is_some() && a.start < b.end && b.start < a.end
})
.penalize_hard()
.named("No overlap");

(required_skill, no_overlap)
(unassigned, required_skill, no_overlap)
}
```

Expand All @@ -275,9 +286,9 @@ struct AssignedShift {
}

let assigned_overlaps = ConstraintFactory::<Schedule, HardSoftScore>::new()
.shifts()
.for_each(Schedule::shifts())
.join((
ConstraintFactory::<Schedule, HardSoftScore>::new().employees(),
ConstraintFactory::<Schedule, HardSoftScore>::new().for_each(Schedule::employees()),
equal_bi(|shift: &Shift| shift.employee, |emp: &Employee| Some(emp.id)),
))
.project(|shift: &Shift, employee: &Employee| AssignedShift {
Expand Down Expand Up @@ -582,11 +593,13 @@ for constraint in &analysis.constraints {

## Examples

See the [`examples/`](examples/) directory:

- **N-Queens**: Classic constraint satisfaction problem
Root workspace examples live under [`examples/`](examples/) as complete solver
packages:

```bash
cargo run -p scalar-graph-coloring
cargo run -p list-tsp
cargo run -p mixed-job-shop
cargo run -p nqueens
```

Expand Down Expand Up @@ -685,16 +698,16 @@ Typical throughput: 300k-1M moves/second depending on constraint complexity for
- Release notes are managed in `CHANGELOG.md` by commit-and-tag workflow.

- **Modern CLI templates**: The standalone CLI introduced first-class application scaffolds around the retained `SolverManager` + `Solvable` + `solver.toml` API. The current CLI has since consolidated those starters behind the neutral `solverforge new ...` shell plus `solverforge generate ...` domain shaping. No manual solver loops, no sub-crate imports — only the `solverforge` facade crate.
- **Generated domain accessors**: `#[planning_solution]` generates a `{Name}ConstraintStreams` trait with typed `.field_name()` methods on `ConstraintFactory` — e.g., `factory.shifts()` instead of `factory.for_each(|s| &s.shifts)`
- **Generated model sources**: `#[planning_solution]` generates inherent source functions such as `Schedule::shifts()` and `Schedule::employees()` for use with `ConstraintFactory::for_each(...)`
- **Ergonomic extractors**: `CollectionExtract<S>` trait accepts both `|s| s.field.as_slice()` and `|s| &s.field` (via `vec(|s| &s.field)`) — no forced `.as_slice()` at every call site
- **Generated `.unassigned()` filter**: entities with `Option` planning variables get a `{Entity}UnassignedFilter` trait — e.g., `factory.shifts().unassigned()` filters to unassigned entities
- **Projected scoring rows**: generated accessors support `.project(...)` with named bounded projection types, creating scoring-only rows without materialized facts.
- **Generated `.unassigned()` support**: entities with exactly one `Option` planning variable can call `.unassigned()` on streams created from model sources
- **Projected scoring rows**: model source streams support `.project(...)` with named bounded projection types, creating scoring-only rows without materialized facts.
- **Convenience scoring**: `penalize_hard()`, `penalize_soft()`, `reward_hard()`, `reward_soft()` on all stream types
- **Single `.join(target)`**: one join method dispatching on argument type — `equal(|a| key)` for self-join, `(extractor_b, equal_bi(ka, kb))` for keyed cross-join, `(other_stream, |a, b| pred)` for predicate join
- **`.named("name")`**: sole finalization method on all builders (replaces `as_constraint`)
- **Score trait**: `one_hard()`, `one_soft()`, `one_medium()` default methods
- **Joiners**: `equal`, `equal_bi`, `less_than`, `less_than_or_equal`, `greater_than`, `greater_than_or_equal`, `overlapping`, `filtering`, with `.and()` composition
- **Conditional existence**: `if_exists(...)`, `if_not_exists(...)` over generated/source-aware collection targets, including flattened collection existence for nested list membership
- **Conditional existence**: `if_exists(...)`, `if_not_exists(...)` over model-owned collection targets, including flattened collection existence for nested list membership

### What's New in 0.5.15

Expand Down
6 changes: 3 additions & 3 deletions crates/solverforge-macros/WIREFRAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Applies to structs. Adds derives: `Clone, Debug, PartialEq, Eq, ProblemFactImpl`
- Hidden scalar metadata bridge: private indexed helpers for scalar variable count, name, allows-unassigned, value-source metadata, getter/setter, and entity-local value slices. Helper order matches `entity_descriptor()` genuine scalar variable order; the index is used for generated getter/setter dispatch, while manifest hook attachment resolves descriptor variables by descriptor index plus variable name.
- Hidden list metadata bridge (when the entity has a `#[planning_list_variable]` field): public cross-module `__SOLVERFORGE_LIST_VARIABLE_COUNT` plus private `__SOLVERFORGE_LIST_VARIABLE_NAME`, `__SOLVERFORGE_LIST_ELEMENT_COLLECTION`, `__solverforge_list_field()`, `__solverforge_list_field_mut()`, `__solverforge_list_metadata()`
- Hidden typed list bridge (when the entity has a `#[planning_list_variable]` field): `impl __internal::ListVariableEntity<Solution> for Entity`
- `pub trait {Entity}UnassignedFilter<...>` (when the entity has exactly one `Option<_>` planning variable) — `.unassigned()` on `UniConstraintStream<_, Entity, ...>`, including generated accessor streams from `#[planning_solution]`
- Hidden unassigned bridge (when the entity has exactly one `Option<_>` planning variable): `impl __internal::UnassignedEntity<Solution> for Entity`, enabling `.unassigned()` on `UniConstraintStream<_, Entity, ...>` without a generated public trait import

### `PlanningSolutionImpl`

Expand Down Expand Up @@ -155,7 +155,7 @@ Applies to structs. Adds derives: `Clone, Debug, PartialEq, Eq, ProblemFactImpl`
- `impl Solvable for T` (when constraints path specified) — `solve(self, runtime: SolverRuntime<Self>)` 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>)` (when constraints path specified) — calls `run_solver()` for macro-generated solving, or loads `solver.toml` and passes it through the configured `config = "..."` callback before calling `run_solver_with_config()`; generated runtime helpers build one `ModelContext` containing typed scalar contexts plus zero or more owner-specific list contexts, delegate scalar hook attachment to the `planning_model!` support impl, sort those variable contexts to the descriptor-backed variable order emitted by the macros, compute hidden shape-aware solve-start telemetry (`__solverforge_total_list_elements()` for list models and `__solverforge_scalar_candidate_count()` for scalar models), and then call hidden `build_phases(config, &descriptor, &model)`
- `pub trait {Name}ConstraintStreams<Sc>` — accessor methods for all `#[planning_entity_collection]` and `#[problem_fact_collection]` fields; implemented on `ConstraintFactory<{Name}, Sc>`. Each accessor returns a `UniConstraintStream` backed by `SourceExtract<fn(&Solution) -> &[Item]>`, using `ChangeSource::Descriptor(idx)` for planning entities and `ChangeSource::Static` for problem facts so generated streams work with incremental `.if_exists(...)` / `.if_not_exists(...)`, `.project(...)`, and `.unassigned()`. There is still only one public `for_each` entrypoint.
- 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 `impl solverforge::stream::CollectionExtract<Plan, Item = Task>`, and carries hidden `ChangeSource::Descriptor(idx)` for planning entities or `ChangeSource::Static` for facts and list elements. User constraints call `ConstraintFactory::new().for_each(Plan::tasks())`; there is still only one public stream-entry verb.

### `ProblemFactImpl`

Expand Down Expand Up @@ -193,7 +193,7 @@ Applies to structs. Adds derives: `Clone, Debug, PartialEq, Eq, ProblemFactImpl`
| `generate_list_operations` | `fn(&Fields) -> TokenStream` | Generates the private runtime helper family, public owner-scoped list methods, and guarded single-owner generic methods without relying on bare-name metadata lookup |
| `generate_solvable_solution` | `fn(&Ident, &Option<String>) -> TokenStream` | Generates SolvableSolution/Solvable/Analyzable impls |
| `generate_shadow_support` | `fn(&ShadowConfig, &Fields, &Ident) -> Result<TokenStream, Error>` | Generates `PlanningSolution` shadow method overrides |
| `generate_constraint_stream_extensions` | `fn(&Fields, &Ident) -> TokenStream` | Generates `{Name}ConstraintStreams` trait + impl on ConstraintFactory |
| `generate_collection_source_methods` | `fn(&Fields) -> TokenStream` | Generates inherent solution source methods used with ConstraintFactory::for_each |
| `extract_option_inner_type` | `fn(&Type) -> Result<&Type, Error>` | Extracts `T` from `Option<T>` |
| `extract_collection_inner_type` | `fn(&Type) -> Option<&Type>` | Extracts `T` from `Vec<T>` |

Expand Down
2 changes: 1 addition & 1 deletion crates/solverforge-macros/src/planning_entity/expand.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Error, Fields};
use syn::{parse_quote, Data, DeriveInput, Error, Fields};

use crate::attr_parse::{
attribute_argument_names, get_attribute, has_attribute, has_attribute_argument,
Expand Down
76 changes: 16 additions & 60 deletions crates/solverforge-macros/src/planning_entity/expand/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,69 +271,25 @@ pub(crate) fn expand_derive(input: DeriveInput) -> Result<TokenStream, Error> {

let unassigned_filter_extension = if optional_planning_variables.len() == 1 {
let (field_name, field_type) = optional_planning_variables[0];
let predicate_name = syn::Ident::new(
&format!(
"__{}_{}_unassigned",
name.to_string().to_lowercase(),
field_name
),
proc_macro2::Span::call_site(),
);
let filter_trait_name = syn::Ident::new(
&format!("{}UnassignedFilter", name),
proc_macro2::Span::call_site(),
);
let mut unassigned_generics = generics.clone();
unassigned_generics
.params
.push(parse_quote!(__SolverForgeSolution));
unassigned_generics
.make_where_clause()
.predicates
.push(parse_quote!(__SolverForgeSolution: ::solverforge::__internal::PlanningSolution));
let (unassigned_impl_generics, _, unassigned_where_clause) =
unassigned_generics.split_for_impl();

quote! {
#[allow(non_snake_case)]
fn #predicate_name<Solution>(
_solution: &Solution,
entity: &#name,
) -> bool
where
Solution: ::solverforge::__internal::PlanningSolution,
impl #unassigned_impl_generics ::solverforge::__internal::UnassignedEntity<__SolverForgeSolution>
for #name #ty_generics
#unassigned_where_clause
{
let value: &::core::option::Option<#field_type> = &entity.#field_name;
value.is_none()
}

pub trait #filter_trait_name<Sc: ::solverforge::Score + 'static, Solution, E, F> {
type Output;
fn unassigned(self) -> Self::Output;
}

impl<Sc, Solution, E, F> #filter_trait_name<Sc, Solution, E, F>
for ::solverforge::__internal::UniConstraintStream<Solution, #name, E, F, Sc>
where
Sc: ::solverforge::Score + 'static,
Solution: ::solverforge::__internal::PlanningSolution,
E: ::solverforge::__internal::CollectionExtract<Solution, Item = #name>,
F: ::solverforge::__internal::UniFilter<Solution, #name>,
{
type Output = ::solverforge::__internal::UniConstraintStream<
Solution,
#name,
E,
::solverforge::__internal::AndUniFilter<
F,
::solverforge::__internal::FnUniFilter<
fn(&Solution, &#name) -> bool
>,
>,
Sc,
>;

fn unassigned(self) -> Self::Output {
let (extractor, filter) = self.into_parts();
::solverforge::__internal::UniConstraintStream::from_parts(
extractor,
::solverforge::__internal::AndUniFilter::new(
filter,
::solverforge::__internal::FnUniFilter::new(
#predicate_name::<Solution> as fn(&Solution, &#name) -> bool
),
),
)
fn is_unassigned(_solution: &__SolverForgeSolution, entity: &Self) -> bool {
let value: &::core::option::Option<#field_type> = &entity.#field_name;
value.is_none()
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/solverforge-macros/src/planning_entity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ fn golden_entity_expansion_includes_descriptor_and_planning_id() {
assert!(expanded.contains("const HAS_LIST_VARIABLE : bool = true"));
assert!(expanded.contains("LIST_ELEMENT_SOURCE"));
assert!(expanded.contains("fn __solverforge_list_metadata < Solution >"));
assert!(expanded.contains("pub trait TaskUnassignedFilter"));
assert!(expanded.contains("fn unassigned (self)"));
assert!(expanded.contains("UnassignedEntity < __SolverForgeSolution > for Task"));
assert!(expanded.contains("fn is_unassigned"));
}

#[test]
Expand Down
7 changes: 3 additions & 4 deletions crates/solverforge-macros/src/planning_solution/expand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use super::runtime::{
generate_runtime_phase_support, generate_runtime_solve_internal, generate_solvable_solution,
};
use super::shadow::generate_shadow_support;
use super::stream_extensions::generate_constraint_stream_extensions;
use super::stream_extensions::generate_collection_source_methods;
use super::type_helpers::{extract_collection_inner_type, extract_option_inner_type};

pub(crate) fn expand_derive(input: DeriveInput) -> Result<TokenStream, Error> {
Expand Down Expand Up @@ -169,7 +169,7 @@ pub(crate) fn expand_derive(input: DeriveInput) -> Result<TokenStream, Error> {
generate_runtime_solve_internal(&constraints_path, &config_path, &solver_toml_path);
let solvable_solution_impl = generate_solvable_solution(name, &constraints_path);

let stream_extensions = generate_constraint_stream_extensions(fields, name);
let collection_source_methods = generate_collection_source_methods(fields);

let expanded = quote! {
impl #impl_generics ::solverforge::__internal::PlanningSolution for #name #ty_generics #where_clause {
Expand Down Expand Up @@ -206,15 +206,14 @@ pub(crate) fn expand_derive(input: DeriveInput) -> Result<TokenStream, Error> {
}

#(#collection_accessors)*
#collection_source_methods

#list_operations
#runtime_solve_internal
}

#runtime_phase_support
#solvable_solution_impl

#stream_extensions
};

Ok(expanded)
Expand Down
Loading
Loading