1use crate::constraint::{Assignment, Constraint, Explanation, PropagationResult};
19use crate::model::domain::TrailedDomains;
20use crate::model::variable::VariableId;
21use std::collections::HashMap;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct BucketedTask {
27 pub start: VariableId,
29 pub duration: i64,
31 pub demand: i64,
33 pub presence: Option<VariableId>,
36}
37
38impl BucketedTask {
39 pub fn new(start: VariableId, duration: i64, demand: i64) -> Self {
44 Self {
45 start,
46 duration,
47 demand,
48 presence: None,
49 }
50 }
51
52 pub fn with_presence(mut self, presence: VariableId) -> Self {
57 self.presence = Some(presence);
58 self
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct BucketRange {
68 pub start: i64,
70 pub end: i64,
72 pub bucket: usize,
74}
75
76impl BucketRange {
77 pub const fn new(start: i64, end: i64, bucket: usize) -> Self {
82 Self { start, end, bucket }
83 }
84}
85
86#[derive(Debug, Clone)]
93pub struct MaximumBucketLoad {
94 tasks: Vec<BucketedTask>,
95 ranges: Vec<BucketRange>,
96 limit: i64,
97 bucket_count: usize,
98 scope: Vec<VariableId>,
99}
100
101impl MaximumBucketLoad {
102 pub fn new(
108 tasks: impl IntoIterator<Item = BucketedTask>,
109 ranges: impl IntoIterator<Item = BucketRange>,
110 limit: i64,
111 ) -> Self {
112 let tasks: Vec<BucketedTask> = tasks.into_iter().collect();
113 let mut ranges: Vec<BucketRange> = ranges.into_iter().collect();
114 ranges.sort_unstable_by_key(|range| range.start);
115 let bucket_count = ranges
116 .iter()
117 .map(|range| range.bucket + 1)
118 .max()
119 .unwrap_or(0);
120 let mut scope = Vec::new();
121 for task in &tasks {
122 scope.push(task.start);
123 if let Some(presence) = task.presence {
124 scope.push(presence);
125 }
126 }
127 scope.sort_unstable();
128 scope.dedup();
129 Self {
130 tasks,
131 ranges,
132 limit,
133 bucket_count,
134 scope,
135 }
136 }
137
138 pub const fn limit(&self) -> i64 {
142 self.limit
143 }
144
145 fn bucket_loads(&self, assignment: &HashMap<VariableId, i64>) -> Vec<i64> {
151 let mut loads = vec![0i64; self.bucket_count];
152 if self.bucket_count == 0 {
153 return loads;
154 }
155 for task in &self.tasks {
156 if !task_is_present(assignment, task.presence) {
157 continue;
158 }
159 let Some(&start) = assignment.get(&task.start) else {
160 continue;
161 };
162 let end = start.saturating_add(task.duration);
163 for (bucket, overlap) in self.overlaps(start, end) {
164 let contribution = task.demand.saturating_mul(overlap);
165 loads[bucket] = loads[bucket].saturating_add(contribution);
166 }
167 }
168 loads
169 }
170
171 fn overlaps(&self, start: i64, end: i64) -> Vec<(usize, i64)> {
173 if self.ranges.is_empty() || end <= start {
174 return Vec::new();
175 }
176 let first = self
180 .ranges
181 .partition_point(|range| range.start <= start)
182 .saturating_sub(1);
183 let mut overlaps = Vec::new();
184 for range in &self.ranges[first..] {
185 if range.start >= end {
186 break;
187 }
188 let overlap_start = start.max(range.start);
189 let overlap_end = end.min(range.end);
190 if overlap_end > overlap_start {
191 overlaps.push((range.bucket, overlap_end - overlap_start));
192 }
193 }
194 overlaps
195 }
196
197 fn first_excess(&self, assignment: &HashMap<VariableId, i64>) -> Option<(usize, i64)> {
198 self.bucket_loads(assignment)
199 .into_iter()
200 .enumerate()
201 .find(|&(_, load)| load > self.limit)
202 }
203}
204
205fn task_is_present(assignment: &HashMap<VariableId, i64>, presence: Option<VariableId>) -> bool {
207 match presence {
208 None => true,
209 Some(presence) => assignment.get(&presence) == Some(&1),
210 }
211}
212
213impl Constraint for MaximumBucketLoad {
214 fn name(&self) -> &str {
215 "MaximumBucketLoad"
216 }
217
218 fn scope(&self) -> &[VariableId] {
219 &self.scope
220 }
221
222 fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool {
226 self.first_excess(assignment).is_none()
227 }
228
229 fn violations(&self, assignment: &HashMap<VariableId, i64>) -> u32 {
233 self.bucket_loads(assignment)
234 .into_iter()
235 .filter_map(|load| u32::try_from(load.saturating_sub(self.limit)).ok())
236 .fold(0u32, u32::saturating_add)
237 }
238
239 fn explain(&self, assignment: &Assignment) -> Option<Explanation> {
240 let (bucket, load) = self.first_excess(assignment)?;
241 Some(Explanation {
242 constraint_name: "MaximumBucketLoad",
243 involved: self.scope.clone(),
244 message: format!(
245 "bucket {bucket} load {load} exceeds the limit {}",
246 self.limit
247 ),
248 })
249 }
250
251 fn propagate(&self, _domains: &mut TrailedDomains) -> PropagationResult {
255 PropagationResult::Success { changed: false }
256 }
257
258 fn validate(&self) -> Result<(), String> {
259 if self.limit < 0 {
260 return Err("load limit must not be negative".to_string());
261 }
262 for task in &self.tasks {
263 if task.duration < 0 {
264 return Err("task duration must not be negative".to_string());
265 }
266 if task.demand < 0 {
267 return Err("task demand must not be negative".to_string());
268 }
269 }
270 let mut previous_end: Option<i64> = None;
271 for range in &self.ranges {
272 if range.start >= range.end {
273 return Err("bucket range must be non-empty (start < end)".to_string());
274 }
275 if let Some(end) = previous_end
276 && range.start < end
277 {
278 return Err("bucket ranges must not overlap".to_string());
279 }
280 previous_end = Some(range.end);
281 }
282 Ok(())
283 }
284}
285
286#[derive(Debug, Clone)]
309pub struct BucketBlockPattern {
310 tasks: Vec<BucketedTask>,
311 ranges: Vec<BucketRange>,
312 allowed: Vec<Vec<i64>>,
313 scope: Vec<VariableId>,
314}
315
316impl BucketBlockPattern {
317 pub fn new(
325 tasks: impl IntoIterator<Item = BucketedTask>,
326 ranges: impl IntoIterator<Item = BucketRange>,
327 allowed: impl IntoIterator<Item = Vec<i64>>,
328 ) -> Self {
329 let tasks: Vec<BucketedTask> = tasks.into_iter().collect();
330 let mut ranges: Vec<BucketRange> = ranges.into_iter().collect();
331 ranges.sort_unstable_by_key(|range| range.start);
332 let allowed: Vec<Vec<i64>> = allowed
333 .into_iter()
334 .map(|mut pattern| {
335 pattern.sort_unstable_by(|left, right| right.cmp(left));
336 pattern
337 })
338 .collect();
339 let mut scope = Vec::new();
340 for task in &tasks {
341 scope.push(task.start);
342 if let Some(presence) = task.presence {
343 scope.push(presence);
344 }
345 }
346 scope.sort_unstable();
347 scope.dedup();
348 Self {
349 tasks,
350 ranges,
351 allowed,
352 scope,
353 }
354 }
355
356 pub fn allowed(&self) -> &[Vec<i64>] {
360 &self.allowed
361 }
362
363 fn fully_determined(&self, assignment: &HashMap<VariableId, i64>) -> bool {
366 self.tasks.iter().all(|task| {
367 assignment.contains_key(&task.start)
368 && task
369 .presence
370 .is_none_or(|presence| assignment.contains_key(&presence))
371 })
372 }
373
374 fn observed_pattern(&self, assignment: &HashMap<VariableId, i64>) -> Vec<i64> {
379 let mut durations: Vec<i64> = Vec::new();
380 for range in &self.ranges {
381 let mut segments: Vec<(i64, i64)> = self
382 .tasks
383 .iter()
384 .filter(|task| task_is_present(assignment, task.presence))
385 .filter_map(|task| {
386 let &start = assignment.get(&task.start)?;
387 let end = start.saturating_add(task.duration);
388 let overlap_start = start.max(range.start);
389 let overlap_end = end.min(range.end);
390 (overlap_end > overlap_start).then_some((overlap_start, overlap_end))
391 })
392 .collect();
393 segments.sort_unstable();
394
395 let mut blocks: Vec<(i64, i64)> = Vec::new();
396 for (start, end) in segments {
397 match blocks.last_mut() {
398 Some(block) if start <= block.1 => block.1 = block.1.max(end),
400 _ => blocks.push((start, end)),
401 }
402 }
403 durations.extend(blocks.into_iter().map(|(start, end)| end - start));
404 }
405 durations.sort_unstable_by(|left, right| right.cmp(left));
406 durations
407 }
408
409 fn matches(&self, observed: &[i64]) -> bool {
411 self.allowed.iter().any(|allowed| allowed == observed)
412 }
413}
414
415fn render_pattern(pattern: &[i64]) -> String {
417 let blocks: Vec<String> = pattern.iter().map(i64::to_string).collect();
418 format!("[{}]", blocks.join(", "))
419}
420
421impl Constraint for BucketBlockPattern {
422 fn name(&self) -> &str {
423 "BucketBlockPattern"
424 }
425
426 fn scope(&self) -> &[VariableId] {
427 &self.scope
428 }
429
430 fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool {
433 if !self.fully_determined(assignment) {
434 return true;
435 }
436 self.matches(&self.observed_pattern(assignment))
437 }
438
439 fn violations(&self, assignment: &HashMap<VariableId, i64>) -> u32 {
446 if !self.fully_determined(assignment) {
447 return 0;
448 }
449 let observed = self.observed_pattern(assignment);
450 self.allowed
451 .iter()
452 .map(|allowed| {
453 let width = allowed.len().max(observed.len());
454 (0..width)
455 .map(|index| {
456 let wanted = allowed.get(index).copied().unwrap_or(0);
457 let got = observed.get(index).copied().unwrap_or(0);
458 u32::try_from(wanted.abs_diff(got)).unwrap_or(u32::MAX)
459 })
460 .fold(0u32, u32::saturating_add)
461 })
462 .min()
463 .unwrap_or(1)
465 }
466
467 fn explain(&self, assignment: &Assignment) -> Option<Explanation> {
468 if !self.fully_determined(assignment) {
469 return None;
470 }
471 let observed = self.observed_pattern(assignment);
472 if self.matches(&observed) {
473 return None;
474 }
475 let allowed: Vec<String> = self
476 .allowed
477 .iter()
478 .map(|pattern| render_pattern(pattern))
479 .collect();
480 Some(Explanation {
481 constraint_name: "BucketBlockPattern",
482 involved: self.scope.clone(),
483 message: format!(
484 "blocks {} do not match any allowed pattern {}",
485 render_pattern(&observed),
486 allowed.join(" or ")
487 ),
488 })
489 }
490
491 fn propagate(&self, _domains: &mut TrailedDomains) -> PropagationResult {
495 PropagationResult::Success { changed: false }
496 }
497
498 fn validate(&self) -> Result<(), String> {
499 if self.allowed.is_empty() {
500 return Err("at least one allowed block pattern is required".to_string());
501 }
502 for pattern in &self.allowed {
503 if pattern.is_empty() {
504 return Err("an allowed block pattern must not be empty".to_string());
505 }
506 if pattern.iter().any(|block| *block <= 0) {
507 return Err("block durations must be positive".to_string());
508 }
509 }
510 for task in &self.tasks {
511 if task.duration < 0 {
512 return Err("task duration must not be negative".to_string());
513 }
514 }
515 let mut previous_end: Option<i64> = None;
516 for range in &self.ranges {
517 if range.start >= range.end {
518 return Err("bucket range must be non-empty (start < end)".to_string());
519 }
520 if let Some(end) = previous_end
521 && range.start < end
522 {
523 return Err("bucket ranges must not overlap".to_string());
524 }
525 previous_end = Some(range.end);
526 }
527 Ok(())
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::model::domain::Domain;
535
536 fn assignment(entries: &[(VariableId, i64)]) -> HashMap<VariableId, i64> {
537 entries.iter().copied().collect()
538 }
539
540 #[test]
542 fn exactly_at_the_limit_is_allowed() {
543 let (a, b) = (VariableId(0), VariableId(1));
544 let constraint = MaximumBucketLoad::new(
545 [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
546 [BucketRange::new(0, 5, 0)],
547 2,
548 );
549 assert!(constraint.is_satisfied(&assignment(&[(a, 0), (b, 3)])));
550 }
551
552 #[test]
554 fn above_the_limit_is_rejected() {
555 let (a, b, c) = (VariableId(0), VariableId(1), VariableId(2));
556 let constraint = MaximumBucketLoad::new(
557 [
558 BucketedTask::new(a, 1, 1),
559 BucketedTask::new(b, 1, 1),
560 BucketedTask::new(c, 1, 1),
561 ],
562 [BucketRange::new(0, 5, 0)],
563 2,
564 );
565 assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (b, 3), (c, 4)])));
566 let explanation = constraint
567 .explain(&assignment(&[(a, 0), (b, 3), (c, 4)]))
568 .expect("violation is explained");
569 assert_eq!(explanation.constraint_name, "MaximumBucketLoad");
570 assert!(explanation.message.contains("limit 2"));
571 }
572
573 #[test]
575 fn occupied_time_is_split_at_bucket_boundaries() {
576 let a = VariableId(0);
577 let constraint = MaximumBucketLoad::new(
579 [BucketedTask::new(a, 3, 1)],
580 [BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)],
581 2,
582 );
583 assert!(constraint.is_satisfied(&assignment(&[(a, 3)])));
584
585 let stricter = MaximumBucketLoad::new(
586 [BucketedTask::new(a, 3, 1)],
587 [BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)],
588 1,
589 );
590 assert!(
591 !stricter.is_satisfied(&assignment(&[(a, 3)])),
592 "day 0 receives 2 units, which exceeds the limit of 1"
593 );
594 }
595
596 #[test]
599 fn bucket_boundaries_are_half_open() {
600 let a = VariableId(0);
601 let ranges = [BucketRange::new(0, 5, 0), BucketRange::new(5, 10, 1)];
602 let at_boundary = MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], ranges, 1);
603 assert!(
604 at_boundary.is_satisfied(&assignment(&[(a, 5)])),
605 "start 5 belongs to bucket 1, not bucket 0"
606 );
607
608 let just_below = MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], ranges, 0);
609 assert!(
610 !just_below.is_satisfied(&assignment(&[(a, 4)])),
611 "start 4 is the last value of bucket 0"
612 );
613 }
614
615 #[test]
618 fn absent_tasks_do_not_count() {
619 let (a, presence) = (VariableId(0), VariableId(9));
620 let constraint = MaximumBucketLoad::new(
621 [BucketedTask::new(a, 4, 1).with_presence(presence)],
622 [BucketRange::new(0, 5, 0)],
623 1,
624 );
625 assert!(constraint.is_satisfied(&assignment(&[(a, 0), (presence, 0)])));
626 assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (presence, 1)])));
627 assert!(
628 constraint.is_satisfied(&assignment(&[(a, 0)])),
629 "an undecided presence must not yet be counted as load"
630 );
631 }
632
633 #[test]
636 fn demand_scales_the_contribution() {
637 let (a, b) = (VariableId(0), VariableId(1));
638 let constraint = MaximumBucketLoad::new(
639 [BucketedTask::new(a, 2, 2), BucketedTask::new(b, 1, 1)],
640 [BucketRange::new(0, 10, 0)],
641 4,
642 );
643 assert!(!constraint.is_satisfied(&assignment(&[(a, 0), (b, 0)])));
645 let restricted = MaximumBucketLoad::new(
646 [BucketedTask::new(a, 2, 2), BucketedTask::new(b, 1, 1)],
647 [BucketRange::new(0, 10, 0)],
648 5,
649 );
650 assert!(restricted.is_satisfied(&assignment(&[(a, 0), (b, 0)])));
651 }
652
653 #[test]
655 fn values_outside_all_buckets_are_ignored() {
656 let a = VariableId(0);
657 let constraint =
658 MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], [BucketRange::new(0, 5, 0)], 0);
659 assert!(constraint.is_satisfied(&assignment(&[(a, 7)])));
660 }
661
662 #[test]
665 fn builder_and_solver_respect_the_bucket_cap() {
666 use crate::constraint::bucket_load::{BucketRange as Range, BucketedTask as Task};
667 use crate::dsl::ModelBuilder;
668 use crate::solver::{BacktrackingSolver, SolverOptions};
669
670 let mut builder = ModelBuilder::new();
671 let a = builder.new_var("a", 0..=1);
672 let b = builder.new_var("b", 0..=1);
673 builder.add_maximum_bucket_load(
674 [Task::new(a, 1, 1), Task::new(b, 1, 1)],
675 [Range::new(0, 1, 0), Range::new(1, 2, 1)],
676 1,
677 );
678 let graph = builder.build().expect("model should validate");
679 let solution = BacktrackingSolver::new()
680 .solve(&graph, &SolverOptions::default())
681 .solution
682 .expect("feasible: the two tasks can occupy different buckets");
683 assert_ne!(solution.assignment[&a], solution.assignment[&b]);
684 }
685
686 #[test]
688 fn propagate_is_a_no_op() {
689 let a = VariableId(0);
690 let constraint =
691 MaximumBucketLoad::new([BucketedTask::new(a, 1, 1)], [BucketRange::new(0, 5, 0)], 1);
692 let mut domains = TrailedDomains::new(HashMap::from([(a, Domain::range(0, 5))]));
693 assert_eq!(
694 constraint.propagate(&mut domains),
695 PropagationResult::Success { changed: false }
696 );
697 }
698
699 #[test]
702 fn touching_slots_are_one_block() {
703 let (a, b) = (VariableId(0), VariableId(1));
704 let tasks = || [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)];
705 let ranges = [BucketRange::new(0, 5, 0)];
706 let adjacent = assignment(&[(a, 1), (b, 2)]);
707
708 let double = BucketBlockPattern::new(tasks(), ranges, [vec![2]]);
709 assert!(
710 double.is_satisfied(&adjacent),
711 "slots 1 and 2 touch and therefore form a block of two"
712 );
713
714 let singles = BucketBlockPattern::new(tasks(), ranges, [vec![1, 1]]);
715 assert!(
716 !singles.is_satisfied(&adjacent),
717 "adjacent slots must not be read as two separate single blocks"
718 );
719 }
720
721 #[test]
724 fn a_gap_splits_the_block() {
725 let (a, b) = (VariableId(0), VariableId(1));
726 let tasks = || [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)];
727 let ranges = [BucketRange::new(0, 5, 0)];
728 let apart = assignment(&[(a, 1), (b, 3)]);
729
730 assert!(BucketBlockPattern::new(tasks(), ranges, [vec![1, 1]]).is_satisfied(&apart));
731 assert!(!BucketBlockPattern::new(tasks(), ranges, [vec![2]]).is_satisfied(&apart));
732 }
733
734 #[test]
737 fn blocks_are_aggregated_across_buckets() {
738 let (a, b) = (VariableId(0), VariableId(1));
739 let ranges = [BucketRange::new(0, 2, 0), BucketRange::new(5, 7, 1)];
740 let constraint = BucketBlockPattern::new(
741 [BucketedTask::new(a, 2, 1), BucketedTask::new(b, 1, 1)],
742 ranges,
743 [vec![2, 1]],
744 );
745 assert!(constraint.is_satisfied(&assignment(&[(a, 0), (b, 5)])));
746 assert!(
747 !constraint.is_satisfied(&assignment(&[(a, 0), (b, 3)])),
748 "b at 3 lies outside every bucket, so only the double period remains"
749 );
750 }
751
752 #[test]
755 fn values_outside_all_buckets_contribute_no_block() {
756 let a = VariableId(0);
757 let constraint = BucketBlockPattern::new(
758 [BucketedTask::new(a, 1, 1)],
759 [BucketRange::new(0, 5, 0)],
760 [vec![1]],
761 );
762 assert!(!constraint.is_satisfied(&assignment(&[(a, 7)])));
763 }
764
765 #[test]
767 fn a_partial_assignment_is_never_a_violation() {
768 let (a, b) = (VariableId(0), VariableId(1));
769 let constraint = BucketBlockPattern::new(
770 [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
771 [BucketRange::new(0, 5, 0)],
772 [vec![2]],
773 );
774 assert!(constraint.is_satisfied(&assignment(&[(a, 1)])));
775 assert!(
776 !constraint.is_satisfied(&assignment(&[(a, 1), (b, 3)])),
777 "once both slots are placed the gap makes the block shape [1, 1]"
778 );
779 }
780
781 #[test]
783 fn an_undecided_presence_is_not_judged() {
784 let (a, presence) = (VariableId(0), VariableId(9));
785 let constraint = BucketBlockPattern::new(
786 [BucketedTask::new(a, 2, 1).with_presence(presence)],
787 [BucketRange::new(0, 5, 0)],
788 [vec![2]],
789 );
790 assert!(constraint.is_satisfied(&assignment(&[(a, 0)])));
791 assert!(constraint.is_satisfied(&assignment(&[(a, 0), (presence, 1)])));
792 assert!(
793 !constraint.is_satisfied(&assignment(&[(a, 0), (presence, 0)])),
794 "an absent task contributes no block, so [] matches nothing here"
795 );
796 }
797
798 #[test]
800 fn the_explanation_names_the_mismatch() {
801 let (a, b) = (VariableId(0), VariableId(1));
802 let constraint = BucketBlockPattern::new(
803 [BucketedTask::new(a, 1, 1), BucketedTask::new(b, 1, 1)],
804 [BucketRange::new(0, 5, 0)],
805 [vec![2]],
806 );
807 let assignment = assignment(&[(a, 0), (b, 2)]);
808 let explanation = constraint
809 .explain(&assignment)
810 .expect("a mismatch is explained");
811 assert_eq!(explanation.constraint_name, "BucketBlockPattern");
812 assert!(
813 explanation.message.contains("[1, 1]"),
814 "{}",
815 explanation.message
816 );
817 assert!(
818 explanation.message.contains("[2]"),
819 "{}",
820 explanation.message
821 );
822 }
823
824 #[test]
827 fn builder_and_solver_respect_the_block_pattern() {
828 use crate::constraint::bucket_load::{BucketRange as Range, BucketedTask as Task};
829 use crate::dsl::ModelBuilder;
830 use crate::solver::{BacktrackingSolver, SolverOptions};
831
832 let mut builder = ModelBuilder::new();
833 let a = builder.new_var("a", 0..=3);
834 let b = builder.new_var("b", 0..=3);
835 builder.add_bucket_block_pattern(
836 [Task::new(a, 1, 1), Task::new(b, 1, 1)],
837 [Range::new(0, 4, 0)],
838 [vec![2]],
839 );
840 let graph = builder.build().expect("model should validate");
841 let solution = BacktrackingSolver::new()
842 .solve(&graph, &SolverOptions::default())
843 .solution
844 .expect("feasible: the two slots can touch");
845 assert_eq!(
846 (solution.assignment[&a] - solution.assignment[&b]).abs(),
847 1,
848 "only adjacent placements form the required block of two"
849 );
850 }
851}