use crate::constraint::{Assignment, Constraint, Explanation, PropagationResult};
use crate::model::domain::TrailedDomains;
use crate::model::variable::VariableId;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BucketedTask {
pub start: VariableId,
pub duration: i64,
pub demand: i64,
pub presence: Option<VariableId>,
}
impl BucketedTask {
pub fn new(start: VariableId, duration: i64, demand: i64) -> Self {
Self {
start,
duration,
demand,
presence: None,
}
}
pub fn with_presence(mut self, presence: VariableId) -> Self {
self.presence = Some(presence);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BucketRange {
pub start: i64,
pub end: i64,
pub bucket: usize,
}
impl BucketRange {
pub const fn new(start: i64, end: i64, bucket: usize) -> Self {
Self { start, end, bucket }
}
}
#[derive(Debug, Clone)]
pub struct MaximumBucketLoad {
tasks: Vec<BucketedTask>,
ranges: Vec<BucketRange>,
limit: i64,
bucket_count: usize,
scope: Vec<VariableId>,
}
impl MaximumBucketLoad {
pub fn new(
tasks: impl IntoIterator<Item = BucketedTask>,
ranges: impl IntoIterator<Item = BucketRange>,
limit: i64,
) -> Self {
let tasks: Vec<BucketedTask> = tasks.into_iter().collect();
let mut ranges: Vec<BucketRange> = ranges.into_iter().collect();
ranges.sort_unstable_by_key(|range| range.start);
let bucket_count = ranges
.iter()
.map(|range| range.bucket + 1)
.max()
.unwrap_or(0);
let mut scope = Vec::new();
for task in &tasks {
scope.push(task.start);
if let Some(presence) = task.presence {
scope.push(presence);
}
}
scope.sort_unstable();
scope.dedup();
Self {
tasks,
ranges,
limit,
bucket_count,
scope,
}
}
pub const fn limit(&self) -> i64 {
self.limit
}
fn bucket_loads(&self, assignment: &HashMap<VariableId, i64>) -> Vec<i64> {
let mut loads = vec![0i64; self.bucket_count];
if self.bucket_count == 0 {
return loads;
}
for task in &self.tasks {
if !task_is_present(assignment, task.presence) {
continue;
}
let Some(&start) = assignment.get(&task.start) else {
continue;
};
let end = start.saturating_add(task.duration);
for (bucket, overlap) in self.overlaps(start, end) {
let contribution = task.demand.saturating_mul(overlap);
loads[bucket] = loads[bucket].saturating_add(contribution);
}
}
loads
}
fn overlaps(&self, start: i64, end: i64) -> Vec<(usize, i64)> {
if self.ranges.is_empty() || end <= start {
return Vec::new();
}
let first = self
.ranges
.partition_point(|range| range.start <= start)
.saturating_sub(1);
let mut overlaps = Vec::new();
for range in &self.ranges[first..] {
if range.start >= end {
break;
}
let overlap_start = start.max(range.start);
let overlap_end = end.min(range.end);
if overlap_end > overlap_start {
overlaps.push((range.bucket, overlap_end - overlap_start));
}
}
overlaps
}
fn first_excess(&self, assignment: &HashMap<VariableId, i64>) -> Option<(usize, i64)> {
self.bucket_loads(assignment)
.into_iter()
.enumerate()
.find(|&(_, load)| load > self.limit)
}
}
fn task_is_present(assignment: &HashMap<VariableId, i64>, presence: Option<VariableId>) -> bool {
match presence {
None => true,
Some(presence) => assignment.get(&presence) == Some(&1),
}
}
impl Constraint for MaximumBucketLoad {
fn name(&self) -> &str {
"MaximumBucketLoad"
}
fn scope(&self) -> &[VariableId] {
&self.scope
}
fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool {
self.first_excess(assignment).is_none()
}
fn violations(&self, assignment: &HashMap<VariableId, i64>) -> u32 {
self.bucket_loads(assignment)
.into_iter()
.filter_map(|load| u32::try_from(load.saturating_sub(self.limit)).ok())
.fold(0u32, u32::saturating_add)
}
fn explain(&self, assignment: &Assignment) -> Option<Explanation> {
let (bucket, load) = self.first_excess(assignment)?;
Some(Explanation {
constraint_name: "MaximumBucketLoad",
involved: self.scope.clone(),
message: format!(
"bucket {bucket} load {load} exceeds the limit {}",
self.limit
),
})
}
fn propagate(&self, _domains: &mut TrailedDomains) -> PropagationResult {
PropagationResult::Success { changed: false }
}
fn validate(&self) -> Result<(), String> {
if self.limit < 0 {
return Err("load limit must not be negative".to_string());
}
for task in &self.tasks {
if task.duration < 0 {
return Err("task duration must not be negative".to_string());
}
if task.demand < 0 {
return Err("task demand must not be negative".to_string());
}
}
let mut previous_end: Option<i64> = None;
for range in &self.ranges {
if range.start >= range.end {
return Err("bucket range must be non-empty (start < end)".to_string());
}
if let Some(end) = previous_end
&& range.start < end
{
return Err("bucket ranges must not overlap".to_string());
}
previous_end = Some(range.end);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct BucketBlockPattern {
tasks: Vec<BucketedTask>,
ranges: Vec<BucketRange>,
allowed: Vec<Vec<i64>>,
scope: Vec<VariableId>,
}
impl BucketBlockPattern {
pub fn new(
tasks: impl IntoIterator<Item = BucketedTask>,
ranges: impl IntoIterator<Item = BucketRange>,
allowed: impl IntoIterator<Item = Vec<i64>>,
) -> Self {
let tasks: Vec<BucketedTask> = tasks.into_iter().collect();
let mut ranges: Vec<BucketRange> = ranges.into_iter().collect();
ranges.sort_unstable_by_key(|range| range.start);
let allowed: Vec<Vec<i64>> = allowed
.into_iter()
.map(|mut pattern| {
pattern.sort_unstable_by(|left, right| right.cmp(left));
pattern
})
.collect();
let mut scope = Vec::new();
for task in &tasks {
scope.push(task.start);
if let Some(presence) = task.presence {
scope.push(presence);
}
}
scope.sort_unstable();
scope.dedup();
Self {
tasks,
ranges,
allowed,
scope,
}
}
pub fn allowed(&self) -> &[Vec<i64>] {
&self.allowed
}
fn fully_determined(&self, assignment: &HashMap<VariableId, i64>) -> bool {
self.tasks.iter().all(|task| {
assignment.contains_key(&task.start)
&& task
.presence
.is_none_or(|presence| assignment.contains_key(&presence))
})
}
fn observed_pattern(&self, assignment: &HashMap<VariableId, i64>) -> Vec<i64> {
let mut durations: Vec<i64> = Vec::new();
for range in &self.ranges {
let mut segments: Vec<(i64, i64)> = self
.tasks
.iter()
.filter(|task| task_is_present(assignment, task.presence))
.filter_map(|task| {
let &start = assignment.get(&task.start)?;
let end = start.saturating_add(task.duration);
let overlap_start = start.max(range.start);
let overlap_end = end.min(range.end);
(overlap_end > overlap_start).then_some((overlap_start, overlap_end))
})
.collect();
segments.sort_unstable();
let mut blocks: Vec<(i64, i64)> = Vec::new();
for (start, end) in segments {
match blocks.last_mut() {
Some(block) if start <= block.1 => block.1 = block.1.max(end),
_ => blocks.push((start, end)),
}
}
durations.extend(blocks.into_iter().map(|(start, end)| end - start));
}
durations.sort_unstable_by(|left, right| right.cmp(left));
durations
}
fn matches(&self, observed: &[i64]) -> bool {
self.allowed.iter().any(|allowed| allowed == observed)
}
}
fn render_pattern(pattern: &[i64]) -> String {
let blocks: Vec<String> = pattern.iter().map(i64::to_string).collect();
format!("[{}]", blocks.join(", "))
}
impl Constraint for BucketBlockPattern {
fn name(&self) -> &str {
"BucketBlockPattern"
}
fn scope(&self) -> &[VariableId] {
&self.scope
}
fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool {
if !self.fully_determined(assignment) {
return true;
}
self.matches(&self.observed_pattern(assignment))
}
fn violations(&self, assignment: &HashMap<VariableId, i64>) -> u32 {
if !self.fully_determined(assignment) {
return 0;
}
let observed = self.observed_pattern(assignment);
self.allowed
.iter()
.map(|allowed| {
let width = allowed.len().max(observed.len());
(0..width)
.map(|index| {
let wanted = allowed.get(index).copied().unwrap_or(0);
let got = observed.get(index).copied().unwrap_or(0);
u32::try_from(wanted.abs_diff(got)).unwrap_or(u32::MAX)
})
.fold(0u32, u32::saturating_add)
})
.min()
.unwrap_or(1)
}
fn explain(&self, assignment: &Assignment) -> Option<Explanation> {
if !self.fully_determined(assignment) {
return None;
}
let observed = self.observed_pattern(assignment);
if self.matches(&observed) {
return None;
}
let allowed: Vec<String> = self
.allowed
.iter()
.map(|pattern| render_pattern(pattern))
.collect();
Some(Explanation {
constraint_name: "BucketBlockPattern",
involved: self.scope.clone(),
message: format!(
"blocks {} do not match any allowed pattern {}",
render_pattern(&observed),
allowed.join(" or ")
),
})
}
fn propagate(&self, _domains: &mut TrailedDomains) -> PropagationResult {
PropagationResult::Success { changed: false }
}
fn validate(&self) -> Result<(), String> {
if self.allowed.is_empty() {
return Err("at least one allowed block pattern is required".to_string());
}
for pattern in &self.allowed {
if pattern.is_empty() {
return Err("an allowed block pattern must not be empty".to_string());
}
if pattern.iter().any(|block| *block <= 0) {
return Err("block durations must be positive".to_string());
}
}
for task in &self.tasks {
if task.duration < 0 {
return Err("task duration must not be negative".to_string());
}
}
let mut previous_end: Option<i64> = None;
for range in &self.ranges {
if range.start >= range.end {
return Err("bucket range must be non-empty (start < end)".to_string());
}
if let Some(end) = previous_end
&& range.start < end
{
return Err("bucket ranges must not overlap".to_string());
}
previous_end = Some(range.end);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::domain::Domain;
fn assignment(entries: &[(VariableId, i64)]) -> HashMap<VariableId, i64> {
entries.iter().copied().collect()
}
#[test]
fn exactly_at_the_limit_is_allowed() {
let (a, b) = (VariableId(0), VariableId(1));
let constraint = MaximumBucketLoad::new(
[BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
[BucketRange::new(0, 5, 0)],
2,
);
assert!(constraint.is_satisfied(&assignment(&[(a, 0), (b, 3)])));
}
#[test]
fn above_the_limit_is_rejected() {
let (a, b, c) = (VariableId(0), VariableId(1), VariableId(2));
let constraint = MaximumBucketLoad::new(
[
BucketedTask::new(a, 1, 1),
BucketedTask::new(b, 1, 1),
BucketedTask::new(c, 1, 1),
],
[BucketRange::new(0, 5, 0)],
2,
);
assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (b, 3), (c, 4)])));
let explanation = constraint
.explain(&assignment(&[(a, 0), (b, 3), (c, 4)]))
.expect("violation is explained");
assert_eq!(explanation.constraint_name, "MaximumBucketLoad");
assert!(explanation.message.contains("limit 2"));
}
#[test]
fn occupied_time_is_split_at_bucket_boundaries() {
let a = VariableId(0);
let constraint = MaximumBucketLoad::new(
[BucketedTask::new(a, 3, 1)],
[BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)],
2,
);
assert!(constraint.is_satisfied(&assignment(&[(a, 3)])));
let stricter = MaximumBucketLoad::new(
[BucketedTask::new(a, 3, 1)],
[BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)],
1,
);
assert!(
!stricter.is_satisfied(&assignment(&[(a, 3)])),
"day 0 receives 2 units, which exceeds the limit of 1"
);
}
#[test]
fn bucket_boundaries_are_half_open() {
let a = VariableId(0);
let ranges = [BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)];
let at_boundary = MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], ranges, 1);
assert!(
at_boundary.is_satisfied(&assignment(&[(a, 5)])),
"start 5 belongs to bucket 1, not bucket 0"
);
let just_below = MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], ranges, 0);
assert!(
!just_below.is_satisfied(&assignment(&[(a, 4)])),
"start 4 is the last value of bucket 0"
);
}
#[test]
fn absent_tasks_do_not_count() {
let (a, presence) = (VariableId(0), VariableId(9));
let constraint = MaximumBucketLoad::new(
[BucketedTask::new(a, 4, 1).with_presence(presence)],
[BucketRange::new(0, 5, 0)],
1,
);
assert!(constraint.is_satisfied(&assignment(&[(a, 0), (presence, 0)])));
assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (presence, 1)])));
assert!(
constraint.is_satisfied(&assignment(&[(a, 0)])),
"an undecided presence must not yet be counted as load"
);
}
#[test]
fn demand_scales_the_contribution() {
let (a, b) = (VariableId(0), VariableId(1));
let constraint = MaximumBucketLoad::new(
[BucketedTask::new(a, 2, 2), BucketedTask::new(b, 1, 1)],
[BucketRange::new(0, 10, 0)],
4,
);
assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (b, 0)])));
let restricted = MaximumBucketLoad::new(
[BucketedTask::new(a, 2, 2), BucketedTask::new(b, 1, 1)],
[BucketRange::new(0, 10, 0)],
5,
);
assert!(restricted.is_satisfied(&assignment(&[(a, 0), (b, 0)])));
}
#[test]
fn values_outside_all_buckets_are_ignored() {
let a = VariableId(0);
let constraint =
MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], [BucketRange::new(0, 5, 0)], 0);
assert!(constraint.is_satisfied(&assignment(&[(a, 7)])));
}
#[test]
fn builder_and_solver_respect_the_bucket_cap() {
use crate::constraint::bucket_load::{BucketRange as Range, BucketedTask as Task};
use crate::dsl::ModelBuilder;
use crate::solver::{BacktrackingSolver, SolverOptions};
let mut builder = ModelBuilder::new();
let a = builder.new_var("a", 0..=1);
let b = builder.new_var("b", 0..=1);
builder.add_maximum_bucket_load(
[Task::new(a, 1, 1), Task::new(b, 1, 1)],
[Range::new(0, 1, 0), Range::new(1, 2, 1)],
1,
);
let graph = builder.build().expect("model should validate");
let solution = BacktrackingSolver::new()
.solve(&graph, &SolverOptions::default())
.solution
.expect("feasible: the two tasks can occupy different buckets");
assert_ne!(solution.assignment[&a], solution.assignment[&b]);
}
#[test]
fn propagate_is_a_no_op() {
let a = VariableId(0);
let constraint =
MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], [BucketRange::new(0, 5, 0)], 1);
let mut domains = TrailedDomains::new(HashMap::from([(a, Domain::range(0, 5))]));
assert_eq!(
constraint.propagate(&mut domains),
PropagationResult::Success { changed: false }
);
}
#[test]
fn touching_slots_are_one_block() {
let (a, b) = (VariableId(0), VariableId(1));
let tasks = || [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)];
let ranges = [BucketRange::new(0, 5, 0)];
let adjacent = assignment(&[(a, 1), (b, 2)]);
let double = BucketBlockPattern::new(tasks(), ranges, [vec![2]]);
assert!(
double.is_satisfied(&adjacent),
"slots 1 and 2 touch and therefore form a block of two"
);
let singles = BucketBlockPattern::new(tasks(), ranges, [vec![1, 1]]);
assert!(
!singles.is_satisfied(&adjacent),
"adjacent slots must not be read as two separate single blocks"
);
}
#[test]
fn a_gap_splits_the_block() {
let (a, b) = (VariableId(0), VariableId(1));
let tasks = || [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)];
let ranges = [BucketRange::new(0, 5, 0)];
let apart = assignment(&[(a, 1), (b, 3)]);
assert!(BucketBlockPattern::new(tasks(), ranges, [vec![1, 1]]).is_satisfied(&apart));
assert!(!BucketBlockPattern::new(tasks(), ranges, [vec![2]]).is_satisfied(&apart));
}
#[test]
fn blocks_are_aggregated_across_buckets() {
let (a, b) = (VariableId(0), VariableId(1));
let ranges = [BucketRange::new(0, 2, 0), BucketRange::new(5, 7, 1)];
let constraint = BucketBlockPattern::new(
[BucketedTask::new(a, 2, 1), BucketedTask::new(b, 1, 1)],
ranges,
[vec![2, 1]],
);
assert!(constraint.is_satisfied(&assignment(&[(a, 0), (b, 5)])));
assert!(
!constraint.is_satisfied(&assignment(&[(a, 0), (b, 3)])),
"b at 3 lies outside every bucket, so only the double period remains"
);
}
#[test]
fn values_outside_all_buckets_contribute_no_block() {
let a = VariableId(0);
let constraint = BucketBlockPattern::new(
[BucketedTask::new(a, 1, 1)],
[BucketRange::new(0, 5, 0)],
[vec![1]],
);
assert!(!constraint.is_satisfied(&assignment(&[(a, 7)])));
}
#[test]
fn a_partial_assignment_is_never_a_violation() {
let (a, b) = (VariableId(0), VariableId(1));
let constraint = BucketBlockPattern::new(
[BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
[BucketRange::new(0, 5, 0)],
[vec![2]],
);
assert!(constraint.is_satisfied(&assignment(&[(a, 1)])));
assert!(
!constraint.is_satisfied(&assignment(&[(a, 1), (b, 3)])),
"once both slots are placed the gap makes the block shape [1, 1]"
);
}
#[test]
fn an_undecided_presence_is_not_judged() {
let (a, presence) = (VariableId(0), VariableId(9));
let constraint = BucketBlockPattern::new(
[BucketedTask::new(a, 2, 1).with_presence(presence)],
[BucketRange::new(0, 5, 0)],
[vec![2]],
);
assert!(constraint.is_satisfied(&assignment(&[(a, 0)])));
assert!(constraint.is_satisfied(&assignment(&[(a, 0), (presence, 1)])));
assert!(
!constraint.is_satisfied(&assignment(&[(a, 0), (presence, 0)])),
"an absent task contributes no block, so [] matches nothing here"
);
}
#[test]
fn the_explanation_names_the_mismatch() {
let (a, b) = (VariableId(0), VariableId(1));
let constraint = BucketBlockPattern::new(
[BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
[BucketRange::new(0, 5, 0)],
[vec![2]],
);
let assignment = assignment(&[(a, 0), (b, 2)]);
let explanation = constraint
.explain(&assignment)
.expect("a mismatch is explained");
assert_eq!(explanation.constraint_name, "BucketBlockPattern");
assert!(
explanation.message.contains("[1, 1]"),
"{}",
explanation.message
);
assert!(
explanation.message.contains("[2]"),
"{}",
explanation.message
);
}
#[test]
fn builder_and_solver_respect_the_block_pattern() {
use crate::constraint::bucket_load::{BucketRange as Range, BucketedTask as Task};
use crate::dsl::ModelBuilder;
use crate::solver::{BacktrackingSolver, SolverOptions};
let mut builder = ModelBuilder::new();
let a = builder.new_var("a", 0..=3);
let b = builder.new_var("b", 0..=3);
builder.add_bucket_block_pattern(
[Task::new(a, 1, 1), Task::new(b, 1, 1)],
[Range::new(0, 4, 0)],
[vec![2]],
);
let graph = builder.build().expect("model should validate");
let solution = BacktrackingSolver::new()
.solve(&graph, &SolverOptions::default())
.solution
.expect("feasible: the two slots can touch");
assert_eq!(
(solution.assignment[&a] - solution.assignment[&b]).abs(),
1,
"only adjacent placements form the required block of two"
);
}
}