Skip to main content

unifier/constraint/
bucket_load.rs

1//! Cumulative load cap per discrete value bucket.
2//!
3//! Generalizes "the total occupied duration an entity accumulates inside each recurring period
4//! bucket must stay within `limit`" — the primitive behind a maximum-daily-load style rule —
5//! without any scheduling vocabulary. A *task* is a plain decision variable over integer time
6//! together with an occupied length and a per-time-unit demand; a *bucket* is an explicit
7//! half-open value range. The mapping from a real calendar (days, weeks, ...) onto bucketed value
8//! ranges is the caller's job, so this module stays domain-agnostic.
9//!
10//! Unlike [`crate::constraint::Cumulative`] — which bounds the *simultaneous* load at any instant
11//! — this constraint bounds the *summed* load accumulated across a whole bucket, e.g. all lessons
12//! scheduled within one day.
13//!
14//! Reference:
15//! - Baptiste, P., Le Pape, C., & Nuijten, W. (2001). *Constraint-Based Scheduling*. Springer.
16//!   (Energetic reasoning: cumulative resource usage over a time window.)
17
18use crate::constraint::{Assignment, Constraint, Explanation, PropagationResult};
19use crate::model::domain::TrailedDomains;
20use crate::model::variable::VariableId;
21use std::collections::HashMap;
22
23/// One assignment that contributes `demand * overlap` occupied time to every bucket its interval
24/// `[start, start + duration)` overlaps with.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct BucketedTask {
27    /// Variable holding the task's start value.
28    pub start: VariableId,
29    /// Occupied length in time units (`>= 0`).
30    pub duration: i64,
31    /// Demand per time unit. An overlap of `L` time units contributes `demand * L`.
32    pub demand: i64,
33    /// Optional presence variable (`1` = present). `None` means unconditionally present. A task
34    /// whose presence is assigned `0` contributes nothing.
35    pub presence: Option<VariableId>,
36}
37
38impl BucketedTask {
39    /// Creates an unconditionally present task.
40    ///
41    /// # Complexity
42    /// Time & Space: O(1).
43    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    /// Attaches a presence variable; the task only counts while `presence` is `1`.
53    ///
54    /// # Complexity
55    /// Time & Space: O(1).
56    pub fn with_presence(mut self, presence: VariableId) -> Self {
57        self.presence = Some(presence);
58        self
59    }
60}
61
62/// A half-open value range `[start, end)` mapped to bucket `bucket`.
63///
64/// Ranges of one constraint should be non-overlapping; the same `bucket` may be used by several
65/// (e.g. disjoint) ranges, or by ranges repeated across a period.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct BucketRange {
68    /// Inclusive lower bound.
69    pub start: i64,
70    /// Exclusive upper bound.
71    pub end: i64,
72    /// Bucket this range belongs to.
73    pub bucket: usize,
74}
75
76impl BucketRange {
77    /// Creates a bucket range.
78    ///
79    /// # Complexity
80    /// Time & Space: O(1).
81    pub const fn new(start: i64, end: i64, bucket: usize) -> Self {
82        Self { start, end, bucket }
83    }
84}
85
86/// Caps the summed `demand * occupied time` of all [`BucketedTask`]s inside every [`BucketRange`]
87/// at `limit`.
88///
89/// A task's contribution is attributed to the bucket(s) its interval `[start, start + duration)`
90/// overlaps, split proportionally at range boundaries, so a task spanning two buckets contributes
91/// its overlap to each. A value outside every range contributes to no bucket.
92#[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    /// Creates a load cap, sorting `ranges` by start so overlap lookup is a binary search.
103    ///
104    /// # Complexity
105    /// Time: O(T + R log R) where T = `tasks.len()`, R = `ranges.len()`.
106    /// Space: O(T + R + B) where B = number of distinct buckets.
107    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    /// Returns the configured limit.
139    ///
140    /// Time complexity: O(1).
141    pub const fn limit(&self) -> i64 {
142        self.limit
143    }
144
145    /// Accumulates the load per bucket for the currently determined tasks.
146    ///
147    /// A task counts only if it is present (`presence` unset or assigned `1`) *and* its start value
148    /// is assigned; anything else contributes nothing, keeping a partial assignment from being
149    /// flagged before all its tasks are known.
150    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    /// Returns `(bucket, overlap)` for every bucket range overlapping `[start, end)`.
172    fn overlaps(&self, start: i64, end: i64) -> Vec<(usize, i64)> {
173        if self.ranges.is_empty() || end <= start {
174            return Vec::new();
175        }
176        // First range that could overlap: the last one starting at or before `start` (ranges are
177        // sorted by start). Everything before it ends no later than that range starts, so it
178        // cannot overlap.
179        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
205/// A task contributes only when its presence variable is unset (mandatory) or explicitly `1`.
206fn 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    /// The load sum is monotone: adding tasks or occupied time can only increase it, so an
223    /// already-exceeded bucket from a *partial* assignment can never be repaired by completing the
224    /// remaining variables.
225    fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool {
226        self.first_excess(assignment).is_none()
227    }
228
229    /// The total excess across buckets, not the number of buckets over their limit: a move that
230    /// takes one hour out of a bucket still three hours over has changed something, and the
231    /// search needs to see it.
232    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    /// No domain filtering: a bucket only exceeds its limit once enough tasks are placed, which a
252    /// partial domain view cannot decide soundly without reasoning over every task's remaining
253    /// positions. The hard cap is still enforced by [`Self::is_satisfied`] during search.
254    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/// Enforces that the consecutive blocks a group of tasks occupies form one of the allowed
287/// multisets of block durations.
288///
289/// Where [`MaximumBucketLoad`] *sums* occupied time inside a bucket, this constraint judges the
290/// **shape** the occupied intervals form: within every [`BucketRange`] the intervals are clipped,
291/// touching or overlapping intervals merge into one block, and the resulting block durations of
292/// all ranges together are compared — order irrelevant — against `allowed`. `[2, 1, 1]` therefore
293/// means "one block of two plus two blocks of one", no matter which bucket each block lands in.
294///
295/// Only the shape is judged, so a task's [`BucketedTask::demand`] is ignored here; the buckets
296/// themselves are the caller's mapping from a calendar onto value ranges, keeping this module free
297/// of scheduling vocabulary. The aggregation over *all* ranges mirrors
298/// `schedulr::bucket_load_pattern`, so the solver and a caller reasoning about teaching blocks
299/// share one notion of a block.
300///
301/// A partial assignment is never a violation: the block multiset is not monotone — a later task
302/// can split or join a block — so an undecided task keeps the check optimistic. The constraint is
303/// enforced as a hard check once the search has placed every task.
304///
305/// Reference:
306/// - van Hoeve, W. J., & Katriel, I. (2006). *Global Constraints*. Handbook of Constraint
307///   Programming, Chapter 6. (Global constraints over sets of intervals.)
308#[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    /// Creates the constraint, normalizing every allowed pattern to a descending block multiset so
318    /// the comparison is order-free.
319    ///
320    /// # Complexity
321    /// Time: O(T + R log R + A · P log P) where T = `tasks.len()`, R = `ranges.len()`,
322    /// A = number of allowed patterns, P = length of the longest pattern.
323    /// Space: O(T + R + A · P).
324    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    /// Returns the normalized allowed block multisets, each descending.
357    ///
358    /// Time complexity: O(1).
359    pub fn allowed(&self) -> &[Vec<i64>] {
360        &self.allowed
361    }
362
363    /// Whether every task's start — and, if it has one, its presence — is decided. The block shape
364    /// can only be judged once no further task can still change it.
365    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    /// The occupied blocks of all decided tasks, aggregated over every range, descending.
375    ///
376    /// Mirrors `schedulr::bucket_load_blocks`/`bucket_load_pattern`: blocks are consecutive runs,
377    /// so two touching single slots count as one block of two.
378    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                    // Touching counts as one block: a double period occupies adjacent slots.
399                    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    /// Whether `observed` equals one of the allowed multisets (both descending).
410    fn matches(&self, observed: &[i64]) -> bool {
411        self.allowed.iter().any(|allowed| allowed == observed)
412    }
413}
414
415/// Renders a block multiset for an explanation, e.g. `[2, 1, 1]`.
416fn 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    /// Deliberately optimistic on a partial assignment: until every task is placed the block
431    /// multiset is still open, and rejecting early would prune sound solutions.
432    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    /// The distance to the nearest allowed shape: how many hours would have to move between
440    /// blocks to reach it, block by block against the closest candidate. A shape one hour off
441    /// therefore counts less than one that is three off, which is the gradient a repair follows.
442    ///
443    /// Optimistic on a partial assignment for the same reason [`Self::is_satisfied`] is: until
444    /// every task is placed the shape is still open.
445    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            // No allowed shape at all: nothing can match, so the count must not say "fine".
464            .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    /// No domain filtering: which block shapes remain reachable depends on the joint placement of
492    /// every task, which a per-variable domain view cannot decide soundly. The hard check is still
493    /// enforced by [`Self::is_satisfied`] during search.
494    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    /// Two unit-length tasks that both land in day 0, with a limit of exactly 2, must be allowed.
541    #[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    /// One more unit than the limit in the same bucket must be rejected.
553    #[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    /// A task whose interval crosses a bucket boundary contributes its overlap to each bucket.
574    #[test]
575    fn occupied_time_is_split_at_bucket_boundaries() {
576        let a = VariableId(0);
577        // [3, 6) overlaps day 0 ([0, 5)) by 2 and day 1 ([5, 10)) by 1.
578        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    /// A value-range boundary belongs to the range that starts there (half-open), so a task placed
597    /// exactly at a bucket boundary counts entirely in the new bucket.
598    #[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    /// A presence variable assigned `0` removes the task's contribution entirely; unset presence
616    /// stays optimistic.
617    #[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    /// Multiple participants/resources per activity: two tasks with the same start but different
634    /// demand each add their own contribution (the caller builds one constraint per entity).
635    #[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        // a contributes 2 * 2 = 4, b contributes 1: total 5 > 4.
644        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    /// A value outside every range is not attributed to any bucket.
654    #[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    /// End-to-end through the DSL: a cap of 1 unit per bucket forces the solver to spread two
663    /// otherwise-identical activities across two buckets.
664    #[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    /// Standalone propagation: the constraint deliberately prunes nothing, staying sound.
687    #[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    /// Two unit slots placed next to each other are **one** block of two, which is what a double
700    /// period means — a duration multiset that ignored adjacency would call this "[1, 1]".
701    #[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    /// The same two slots one apart are two single blocks — and under a `[2]`-only pattern that is
722    /// a violation, so the constraint discriminates adjacency in both directions.
723    #[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    /// Blocks may sit in different buckets: the pattern aggregates over all ranges, so a double
735    /// period on one day and a single on another matches `[2, 1]`.
736    #[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    /// A task whose slot lies outside every bucket contributes no block, mirroring
753    /// [`MaximumBucketLoad`].
754    #[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    /// Until every task is placed the block multiset is still open, so the check stays optimistic.
766    #[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    /// An undecided presence also keeps the check open, because the task may still add a block.
782    #[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    /// The violation names both the observed shape and the allowed ones.
799    #[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    /// End-to-end through the DSL and the solver: a course that allows only double periods must end
825    /// up with its two slots adjacent, even though nothing else forces that.
826    #[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}