Skip to content

Commit d85b2df

Browse files
committed
Merge branch 'issue-14-scaffold-smoke-r060-stack' into release/0.6.0
2 parents ba3e1ba + afd5ac6 commit d85b2df

26 files changed

Lines changed: 290 additions & 95 deletions

File tree

crates/solverforge-cli/src/commands/generate_constraint/domain.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,12 @@ pub(crate) fn find_annotated_struct(src: &str, attr: &str) -> Option<String> {
120120
for (i, line) in lines.iter().enumerate() {
121121
let t = line.trim();
122122
if t.contains(&format!("#[{}]", attr)) || t.contains(&format!("#[{}(", attr)) {
123-
// Look ahead for struct definition
124-
for next_line in lines.iter().take(lines.len().min(i + 4)).skip(i + 1) {
123+
// Look ahead past any additional attributes until the struct definition.
124+
for next_line in lines.iter().skip(i + 1) {
125125
let next = next_line.trim();
126+
if next.is_empty() {
127+
continue;
128+
}
126129
if next.starts_with("pub struct ") || next.starts_with("struct ") {
127130
let after = next
128131
.trim_start_matches("pub ")

crates/solverforge-cli/src/commands/generate_constraint/skeleton.rs

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@ pub(crate) fn generate_skeleton(
4343
// Build import line(s)
4444
let imports = match pattern {
4545
Pattern::Join => {
46-
if fact.is_some() {
46+
if fact.is_some() && solution_type != entity_type {
4747
format!(
4848
"use crate::domain::{{{solution_type}, {entity_type}, {fact_type}}};\nuse solverforge::prelude::*;\nuse solverforge::stream::joiner::equal_bi;\nuse solverforge::IncrementalConstraint;",
4949
)
50+
} else if fact.is_some() {
51+
format!(
52+
"use crate::domain::{{{solution_type}, {fact_type}}};\nuse solverforge::prelude::*;\nuse solverforge::stream::joiner::equal_bi;\nuse solverforge::IncrementalConstraint;",
53+
)
5054
} else {
5155
format!(
5256
"use crate::domain::{{{solution_type}, {entity_type}}};\nuse solverforge::prelude::*;\nuse solverforge::stream::joiner::equal_bi;\nuse solverforge::IncrementalConstraint;",
@@ -64,74 +68,97 @@ pub(crate) fn generate_skeleton(
6468
format!("{score_type}::ONE_HARD")
6569
};
6670

67-
let body = match pattern {
71+
let (body, helpers) = match pattern {
6872
Pattern::Unary => {
6973
let action = if is_soft {
7074
format!(" .reward({penalty_expr})")
7175
} else {
7276
format!(" .penalize({penalty_expr})")
7377
};
74-
format!(
75-
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
78+
(
79+
format!(
80+
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
7681
.for_each(|s: &{solution_type}| s.{entity_field}.as_slice())
7782
.filter(|_e: &{entity_type}| {{
7883
panic!("replace placeholder condition before enabling this constraint")
7984
}})
8085
{action}
81-
.as_constraint("{constraint_name}")"#
86+
.named("{constraint_name}")"#
87+
),
88+
String::new(),
8289
)
8390
}
8491

85-
Pattern::Pair => format!(
86-
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
87-
.for_each_unique_pair(
88-
|s: &{solution_type}| s.{entity_field}.as_slice(),
89-
joiner::equal(|e: &{entity_type}| e.{planning_var}),
90-
)
92+
Pattern::Pair => (
93+
format!(
94+
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
95+
.for_each(|s: &{solution_type}| s.{entity_field}.as_slice())
96+
.join(joiner::equal(|e: &{entity_type}| e.{planning_var}))
9197
.filter(|_a: &{entity_type}, _b: &{entity_type}| {{
9298
panic!("replace placeholder pair condition before enabling this constraint")
9399
}})
94100
.penalize({penalty_expr})
95-
.as_constraint("{constraint_name}")"#
101+
.named("{constraint_name}")"#
102+
),
103+
String::new(),
96104
),
97105

98-
Pattern::Join => format!(
99-
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
100-
.for_each(|s: &{solution_type}| s.{entity_field}.as_slice())
101-
.join(
102-
|s: &{solution_type}| s.{fact_field}.as_slice(),
106+
Pattern::Join => (
107+
format!(
108+
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
109+
.for_each(entity_items)
110+
.join((
111+
fact_items,
103112
equal_bi(
104113
|e: &{entity_type}| e.{planning_var},
105114
|_f: &{fact_type}| panic!("replace placeholder join key extractor before enabling this constraint"),
106115
),
107-
)
116+
))
108117
.filter(|_e: &{entity_type}, _f: &{fact_type}| {{
109118
panic!("replace placeholder join condition before enabling this constraint")
110119
}})
111120
.penalize({penalty_expr})
112-
.as_constraint("{constraint_name}")"#
121+
.named("{constraint_name}")"#
122+
),
123+
format!(
124+
r#"
125+
126+
fn entity_items(solution: &{solution_type}) -> &[{entity_type}] {{
127+
solution.{entity_field}.as_slice()
128+
}}
129+
130+
fn fact_items(solution: &{solution_type}) -> &[{fact_type}] {{
131+
solution.{fact_field}.as_slice()
132+
}}"#
133+
),
113134
),
114135

115-
Pattern::Balance => format!(
116-
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
136+
Pattern::Balance => (
137+
format!(
138+
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
117139
.for_each(|s: &{solution_type}| s.{entity_field}.as_slice())
118140
.balance(|e: &{entity_type}| e.{planning_var})
119141
.penalize({score_type}::of_soft(1))
120-
.as_constraint("{constraint_name}")"#
142+
.named("{constraint_name}")"#
143+
),
144+
String::new(),
121145
),
122146

123-
Pattern::Reward => format!(
124-
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
147+
Pattern::Reward => (
148+
format!(
149+
r#" ConstraintFactory::<{solution_type}, {score_type}>::new()
125150
.for_each(|s: &{solution_type}| s.{entity_field}.as_slice())
126151
.filter(|_e: &{entity_type}| {{
127152
panic!("replace placeholder reward condition before enabling this constraint")
128153
}})
129154
.reward({penalty_expr})
130-
.as_constraint("{constraint_name}")"#
155+
.named("{constraint_name}")"#
156+
),
157+
String::new(),
131158
),
132159
};
133160

134161
format!(
135-
"{imports}\n\n/// {hardness_comment}\npub fn constraint() -> impl IncrementalConstraint<{solution_type}, {score_type}> {{\n{body}\n}}\n"
162+
"{imports}\n\n/// {hardness_comment}\npub fn constraint() -> impl IncrementalConstraint<{solution_type}, {score_type}> {{\n{body}\n}}{helpers}\n"
136163
)
137164
}

crates/solverforge-cli/src/commands/generate_constraint/tests.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ fn test_find_annotated_struct() {
101101
);
102102
}
103103

104+
#[test]
105+
fn test_find_annotated_struct_skips_multiline_attrs() {
106+
let src = r#"#[planning_solution(constraints = "crate::constraints::create_constraints")]
107+
#[basic_variable_config(
108+
entity_collection = "shifts",
109+
variable_field = "employee_idx",
110+
variable_type = "usize",
111+
value_range = "employees"
112+
)]
113+
pub struct EmployeeSchedule {
114+
pub score: Option<HardSoftDecimalScore>,
115+
}
116+
"#;
117+
assert_eq!(
118+
find_annotated_struct(src, "planning_solution"),
119+
Some("EmployeeSchedule".to_string())
120+
);
121+
}
122+
104123
#[test]
105124
fn test_find_score_type() {
106125
let src = "#[planning_solution]\npub struct Plan {\n pub score: Option<HardSoftScore>,\n}\n";
@@ -160,7 +179,7 @@ fn test_generate_skeleton_pair_hard() {
160179
"No Overlap",
161180
Some(&domain),
162181
);
163-
assert!(result.contains("for_each_unique_pair"));
182+
assert!(result.contains("for_each(|s: &EmployeeSchedule| s.shifts.as_slice())"));
164183
assert!(result.contains("joiner::equal(|e: &Shift| e.employee_idx)"));
165184
assert!(result.contains(
166185
"panic!(\"replace placeholder pair condition before enabling this constraint\")"

crates/solverforge-cli/templates/basic/employee-scheduling/Cargo.toml.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ serde_json = "1"
2424
chrono = { version = "0.4", features = ["serde"] }
2525

2626
# Utilities
27+
rand = "0.10"
2728
uuid = { version = "1", features = ["v4", "serde"] }
2829
parking_lot = "0.12"
2930
tracing = "0.1"

crates/solverforge-cli/templates/basic/employee-scheduling/src/constraints/balance.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ use solverforge::IncrementalConstraint;
55
/// SOFT: Balance shift assignments evenly across all employees.
66
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
77
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
8-
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
8+
.for_each(shifts)
99
.balance(|shift: &Shift| shift.employee_idx)
1010
.penalize(HardSoftDecimalScore::of_soft(1))
11-
.as_constraint("Balance employee assignments")
11+
.named("Balance employee assignments")
12+
}
13+
14+
fn shifts(schedule: &EmployeeSchedule) -> &[Shift] {
15+
schedule.shifts.as_slice()
1216
}

crates/solverforge-cli/templates/basic/employee-scheduling/src/constraints/no_overlap.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
use crate::domain::{EmployeeSchedule, Shift};
1+
use crate::domain::{EmployeeSchedule, EmployeeScheduleConstraintStreams, Shift};
22
use solverforge::prelude::*;
33
use solverforge::IncrementalConstraint;
44

55
/// HARD: An employee cannot work two overlapping shifts.
66
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
77
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
8-
.for_each_unique_pair(
9-
|s: &EmployeeSchedule| s.shifts.as_slice(),
10-
joiner::equal(|shift: &Shift| shift.employee_idx),
11-
)
8+
.shifts()
9+
.join(joiner::equal(|shift: &Shift| shift.employee_idx))
1210
.filter(|a: &Shift, b: &Shift| {
13-
a.employee_idx.is_some() && a.start < b.end && b.start < a.end
11+
a.id < b.id && a.employee_idx.is_some() && a.start < b.end && b.start < a.end
1412
})
1513
.penalize_hard_with(|a: &Shift, b: &Shift| {
1614
HardSoftDecimalScore::of_hard_scaled(overlap_minutes(a, b) * 100_000)
1715
})
18-
.as_constraint("Overlapping shift")
16+
.named("Overlapping shift")
1917
}
2018

2119
fn overlap_minutes(a: &Shift, b: &Shift) -> i64 {
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
use crate::domain::{EmployeeSchedule, Shift};
1+
use crate::domain::{EmployeeSchedule, EmployeeScheduleConstraintStreams, Shift};
22
use solverforge::prelude::*;
33
use solverforge::IncrementalConstraint;
44

55
/// HARD: An employee can work at most one shift per day.
66
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
77
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
8-
.for_each_unique_pair(
9-
|s: &EmployeeSchedule| s.shifts.as_slice(),
10-
joiner::equal(|shift: &Shift| (shift.employee_idx, shift.date())),
11-
)
12-
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && b.employee_idx.is_some())
8+
.shifts()
9+
.join(joiner::equal(|shift: &Shift| (shift.employee_idx, shift.date())))
10+
.filter(|a: &Shift, b: &Shift| a.id < b.id && a.employee_idx.is_some())
1311
.penalize(HardSoftDecimalScore::ONE_HARD)
14-
.as_constraint("One shift per day")
12+
.named("One shift per day")
1513
}

crates/solverforge-cli/templates/basic/employee-scheduling/src/constraints/required_skill.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,25 @@ use solverforge::IncrementalConstraint;
66
/// HARD: Every shift must be staffed by an employee with the required skill.
77
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
88
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
9-
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
10-
.join(
11-
|s: &EmployeeSchedule| s.employees.as_slice(),
9+
.for_each(shifts)
10+
.join((
11+
employees,
1212
equal_bi(
1313
|shift: &Shift| shift.employee_idx,
1414
|emp: &Employee| Some(emp.index),
1515
),
16-
)
16+
))
1717
.filter(|shift: &Shift, emp: &Employee| {
1818
shift.employee_idx.is_some() && !emp.skills.contains(&shift.required_skill)
1919
})
2020
.penalize(HardSoftDecimalScore::ONE_HARD)
21-
.as_constraint("Required skill")
21+
.named("Required skill")
22+
}
23+
24+
fn shifts(schedule: &EmployeeSchedule) -> &[Shift] {
25+
schedule.shifts.as_slice()
26+
}
27+
28+
fn employees(schedule: &EmployeeSchedule) -> &[Employee] {
29+
schedule.employees.as_slice()
2230
}

crates/solverforge-cli/templates/basic/employee-scheduling/src/constraints/ten_hour_gap.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
1-
use crate::domain::{EmployeeSchedule, Shift};
1+
use crate::domain::{EmployeeSchedule, EmployeeScheduleConstraintStreams, Shift};
22
use solverforge::prelude::*;
33
use solverforge::IncrementalConstraint;
44

55
/// HARD: At least 10 hours must elapse between consecutive shifts for the same employee.
66
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
77
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
8-
.for_each_unique_pair(
9-
|s: &EmployeeSchedule| s.shifts.as_slice(),
10-
joiner::equal(|shift: &Shift| shift.employee_idx),
11-
)
12-
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && gap_penalty(a, b) > 0)
8+
.shifts()
9+
.join(joiner::equal(|shift: &Shift| shift.employee_idx))
10+
.filter(|a: &Shift, b: &Shift| a.id < b.id && a.employee_idx.is_some() && gap_penalty(a, b) > 0)
1311
.penalize_hard_with(|a: &Shift, b: &Shift| {
1412
HardSoftDecimalScore::of_hard_scaled(gap_penalty(a, b) * 100_000)
1513
})
16-
.as_constraint("At least 10 hours between 2 shifts")
14+
.named("At least 10 hours between 2 shifts")
1715
}
1816

1917
fn gap_penalty(a: &Shift, b: &Shift) -> i64 {

crates/solverforge-cli/templates/basic/employee-scheduling/src/constraints/unavailable.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ use solverforge::IncrementalConstraint;
77
/// HARD: An employee cannot be assigned to a shift on a day they are unavailable.
88
pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDecimalScore> {
99
ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new()
10-
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
11-
.join(
12-
|s: &EmployeeSchedule| s.employees.as_slice(),
10+
.for_each(shifts)
11+
.join((
12+
employees,
1313
equal_bi(
1414
|shift: &Shift| shift.employee_idx,
1515
|emp: &Employee| Some(emp.index),
1616
),
17-
)
17+
))
1818
.flatten_last(
1919
|emp: &Employee| emp.unavailable_days.as_slice(),
2020
|date: &NaiveDate| *date,
@@ -26,7 +26,15 @@ pub fn constraint() -> impl IncrementalConstraint<EmployeeSchedule, HardSoftDeci
2626
.penalize_hard_with(|shift: &Shift, date: &NaiveDate| {
2727
HardSoftDecimalScore::of_hard_scaled(overlap_minutes(shift, *date) * 100_000)
2828
})
29-
.as_constraint("Unavailable employee")
29+
.named("Unavailable employee")
30+
}
31+
32+
fn shifts(schedule: &EmployeeSchedule) -> &[Shift] {
33+
schedule.shifts.as_slice()
34+
}
35+
36+
fn employees(schedule: &EmployeeSchedule) -> &[Employee] {
37+
schedule.employees.as_slice()
3038
}
3139

3240
fn overlap_minutes(shift: &Shift, date: NaiveDate) -> i64 {

0 commit comments

Comments
 (0)