Skip to main content

unifier/constraint/
mod.rs

1//! Constraint definitions and propagation traits for CSP/COP modeling.
2//!
3//! References:
4//! - Rossi, F., van Beek, P., & Walsh, T. (2006). *Handbook of Constraint Programming*. Elsevier.
5//! - van Hoeve, W. J., & Katriel, I. (2006). *Global Constraints*. Handbook of Constraint Programming, Chapter 6.
6
7pub mod all_different;
8pub mod bucket_load;
9pub mod cardinality;
10pub mod cumulative;
11pub mod domain_filter;
12pub mod equal;
13pub mod less_than;
14pub mod minimum_distance;
15pub mod no_overlap;
16pub mod not_equal;
17pub mod optional;
18pub mod periodic_values;
19pub mod precedence;
20
21pub use all_different::AllDifferent;
22pub use bucket_load::{BucketBlockPattern, BucketRange, BucketedTask, MaximumBucketLoad};
23pub use cardinality::{AtLeast, AtMost, ExactlyOne};
24pub use cumulative::{Cumulative, TaskDemand};
25pub use domain_filter::{AllowedValues, ForbiddenValues};
26pub use equal::Equal;
27pub use less_than::LessThanOrEqual;
28pub use minimum_distance::MinimumDistance;
29pub use no_overlap::NoOverlap;
30pub use not_equal::NotEqual;
31pub use optional::Optional;
32pub use periodic_values::PeriodicValues;
33pub use precedence::Precedence;
34
35use crate::model::domain::{Domain, TrailedDomains};
36use crate::model::variable::VariableId;
37use std::collections::HashMap;
38use std::fmt::Debug;
39
40/// Concrete values assigned to decision variables.
41pub type Assignment = HashMap<VariableId, i64>;
42
43/// Structured explanation of one violated constraint.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Explanation {
46    /// Stable constraint type name.
47    pub constraint_name: &'static str,
48    /// Variables that concretely participate in the violation.
49    pub involved: Vec<VariableId>,
50    /// Human-readable English explanation intended for logs or direct display.
51    pub message: String,
52}
53
54/// Result of a domain propagation step executed by a constraint.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PropagationResult {
57    /// Propagation succeeded without encountering an empty domain.
58    /// `changed` is true if any variable domain was pruned.
59    Success { changed: bool },
60
61    /// Domain reduction produced an empty domain (conflict/inconsistency detected).
62    Conflict,
63}
64
65/// Core interface for constraints in `unifier`.
66///
67/// Each constraint defines its variable scope, satisfaction checking logic,
68/// and filtering/propagation rule.
69pub trait Constraint: Debug + Send + Sync {
70    /// Returns a human-readable name of the constraint type.
71    fn name(&self) -> &str;
72
73    /// Returns the slice of variable IDs involved in this constraint.
74    ///
75    /// Time complexity: O(1).
76    fn scope(&self) -> &[VariableId];
77
78    /// Evaluates if the constraint is satisfied under a complete or partial variable assignment.
79    ///
80    /// Returns `true` if all variables in scope are assigned and satisfy the condition,
81    /// or if unassigned variables do not violate the constraint yet.
82    fn is_satisfied(&self, assignment: &HashMap<VariableId, i64>) -> bool;
83
84    /// How *many* ways this constraint is broken under `assignment`, not merely whether it is.
85    ///
86    /// `0` exactly when [`Self::is_satisfied`] is `true`; otherwise a count that falls as the
87    /// assignment gets closer to holding it. The default answers `1` for any violation, which
88    /// is what the score counted everywhere before this existed.
89    ///
90    /// A constraint over a handful of variables loses little by answering `1`. One over dozens
91    /// loses the search: resolving a single collision inside an already-broken constraint moves
92    /// the score by nothing, so tabu search, bound pruning and every acceptance rule are blind
93    /// to it — a plateau the size of the constraint's scope. Any constraint whose scope grows
94    /// with the model should count properly.
95    ///
96    /// # Complexity
97    /// May cost more than [`Self::is_satisfied`], which is allowed to stop at the first problem
98    /// it sees while this one has to look at all of them.
99    fn violations(&self, assignment: &HashMap<VariableId, i64>) -> u32 {
100        u32::from(!self.is_satisfied(assignment))
101    }
102
103    /// Explains a concrete violation, or returns `None` when the assignment does not violate
104    /// this constraint or the implementation has no specialized explanation.
105    ///
106    /// # Complexity
107    /// At most the complexity of [`Self::is_satisfied`]; implementations may inspect the
108    /// constraint scope once more to identify the concrete participants.
109    fn explain(&self, assignment: &Assignment) -> Option<Explanation> {
110        let _ = assignment;
111        None
112    }
113
114    /// Enforces arc/bounds consistency by pruning inconsistent values from variable domains.
115    ///
116    /// `domains` is a [`TrailedDomains`], not a bare `HashMap`: mutations made through
117    /// [`TrailedDomains::get_mut`] are recorded so the search solvers can undo a node in
118    /// `O(changed)` instead of cloning the full domain map at every node.
119    fn propagate(&self, domains: &mut TrailedDomains) -> PropagationResult;
120
121    /// Validates the constraint's own parameters independent of any assignment or domain state.
122    ///
123    /// Returns `Err(reason)` for structurally invalid parameters (e.g. a fixed demand exceeding a
124    /// fixed capacity), as opposed to a model that merely turns out to be unsatisfiable through
125    /// the interaction of several constraints. Called once by [`ConstraintGraph::validate`]
126    /// before solving; the default implementation accepts any parameters.
127    ///
128    /// [`ConstraintGraph::validate`]: crate::propagation::graph::ConstraintGraph::validate
129    fn validate(&self) -> Result<(), String> {
130        Ok(())
131    }
132
133    /// Returns `false` only if this constraint can *provably* never be satisfied by any
134    /// completion consistent with the current `domains`, regardless of how the as-yet-unassigned
135    /// variables in its scope are eventually assigned.
136    ///
137    /// Used by [`crate::solver::BranchAndBoundSolver`] (via
138    /// [`crate::score::ScoreCalculator::optimistic_score`]) as an admissible hard-score bound for
139    /// pruning: returning `false` here marks a search subtree as unable to ever become feasible,
140    /// so it must never be `false` merely because the constraint isn't satisfied *yet*.
141    ///
142    /// The default implementation delegates to [`Self::is_satisfied`] on `assignment`, which is
143    /// correct for constraints where a violation detected from a partial assignment can never be
144    /// resolved by completing it further (true for e.g. `Equal`, `NotEqual`, `AllDifferent`,
145    /// `AtMost` — once violated, permanently violated). Constraints whose satisfiability
146    /// genuinely depends on still-unassigned variables (e.g. `ExactlyOne`, `AtLeast`, which can
147    /// still reach their target count later) MUST override this using `domains` instead.
148    fn is_satisfiable(
149        &self,
150        domains: &HashMap<VariableId, Domain>,
151        assignment: &HashMap<VariableId, i64>,
152    ) -> bool {
153        let _ = domains;
154        self.is_satisfied(assignment)
155    }
156}
157
158/// Evaluates a binary comparator over two assigned variables.
159///
160/// Returns `true` if either variable is unassigned (a partial assignment does not yet
161/// violate a not-yet-fully-known comparison), matching the "partial assignment is not
162/// violating" convention used by binary comparison constraints.
163///
164/// # Complexity
165/// Time & Space: O(1).
166pub(crate) fn compare_assigned(
167    assignment: &HashMap<VariableId, i64>,
168    v1: VariableId,
169    v2: VariableId,
170    cmp: impl FnOnce(i64, i64) -> bool,
171) -> bool {
172    match (assignment.get(&v1), assignment.get(&v2)) {
173        (Some(&val1), Some(&val2)) => cmp(val1, val2),
174        _ => true,
175    }
176}
177
178/// Returns the `(min, max)` bounds of `var`'s domain for propagation, distinguishing an
179/// untracked variable from an already-empty domain.
180///
181/// Returns `Err(Success { changed: false })` if `var` is not present in `domains` (nothing to
182/// prune), or `Err(Conflict)` if its domain is already empty.
183///
184/// # Complexity
185/// Time & Space: O(1).
186pub(crate) fn require_bounds(
187    domains: &HashMap<VariableId, Domain>,
188    var: VariableId,
189) -> Result<(i64, i64), PropagationResult> {
190    match domains.get(&var) {
191        Some(d) => match (d.min(), d.max()) {
192            (Some(min), Some(max)) => Ok((min, max)),
193            _ => Err(PropagationResult::Conflict),
194        },
195        None => Err(PropagationResult::Success { changed: false }),
196    }
197}
198
199/// Returns the `(min, max)` bounds of `var`'s domain, or `None` if `var` is untracked or its
200/// domain is empty. Intended for propagation loops that skip rather than abort on a missing
201/// operand (e.g. global constraints iterating over a task list).
202///
203/// # Complexity
204/// Time & Space: O(1).
205pub(crate) fn domain_bounds(
206    domains: &HashMap<VariableId, Domain>,
207    var: VariableId,
208) -> Option<(i64, i64)> {
209    let d = domains.get(&var)?;
210    Some((d.min()?, d.max()?))
211}
212
213/// Applies `narrow` to `var`'s domain, tracking whether it removed any values in `changed` and
214/// returning `Some(Conflict)` if the domain became empty. Returns `None` if `var` is untracked
215/// or narrowing did not exhaust the domain, so the caller can continue.
216///
217/// # Complexity
218/// Time & Space: O(1) plus the cost of `narrow`.
219pub(crate) fn prune(
220    domains: &mut TrailedDomains,
221    changed: &mut bool,
222    var: VariableId,
223    narrow: impl FnOnce(&mut Domain) -> bool,
224) -> Option<PropagationResult> {
225    if domains.mutate(var, narrow)? {
226        *changed = true;
227    }
228    if domains.get(&var)?.is_empty() {
229        return Some(PropagationResult::Conflict);
230    }
231    None
232}
233
234/// Converts a `u64` duration to `i64` for arithmetic with `i64`-typed domain values, saturating
235/// to `i64::MAX` instead of wrapping/panicking for durations exceeding `i64::MAX` (unrealistic in
236/// practice, but not excluded by the `u64` type).
237///
238/// # Complexity
239/// Time & Space: O(1).
240pub(crate) fn duration_as_i64(duration: u64) -> i64 {
241    i64::try_from(duration).unwrap_or(i64::MAX)
242}
243
244/// Checks whether any time window `[a, b)` containing a subset of tasks demands more total
245/// `demand * duration` ("energy") than a resource of `capacity` can provide across that window —
246/// a generalization of pairwise mandatory-part reasoning to sets of three or more tasks whose
247/// individual pairwise overlaps don't reveal an infeasibility that their *combined* demand does
248/// (see `plan/11-search-heuristics-and-global-constraints.md`, part D, for a worked example: 3
249/// tasks with duration 2 each and starts free in `[0,3]` overload a unary resource only as a
250/// triple, not as any pair).
251///
252/// `tasks` gives each task's `(est, lct, energy)`: earliest start, latest completion
253/// (`domain_max + duration`), and `demand * duration` (or just `duration` for a unary resource
254/// with implicit demand 1, e.g. [`crate::constraint::NoOverlap`]). For every candidate window
255/// `[a, b)` — `a`/`b` drawn from the tasks' own `est`/`lct` values, since a tighter window can
256/// only ever be bounded by an actual task edge — sums the energy of every task fully confined to
257/// it (`est(task) >= a && lct(task) <= b`) and compares against `capacity * (b - a)`. If it's
258/// larger, the window cannot accommodate the confined tasks: `Conflict` regardless of what the
259/// rest of the schedule looks like.
260///
261/// This is the sound *detection* half of energetic reasoning / edge-finding — it never reports
262/// an overload that isn't real (soundness proof: every confined task's *entire* domain-feasible
263/// range lies within `[a, b)` by construction, so its whole duration's energy consumption must
264/// fall inside the window regardless of how it's actually scheduled; total energy exceeding
265/// `capacity * window` is then a necessary condition for infeasibility). It intentionally skips
266/// the harder *update* half (tightening `est` bounds for tasks that must run after an
267/// overloaded-adjacent set) — left as future work, see `plan/11-...md` part D.
268///
269/// # Complexity
270/// Time: O(N^3) (N candidate `a` thresholds x N candidate `b` thresholds x O(N) to sum energy
271/// per window) — a straightforward, easily-verified enumeration rather than the O(N log N)
272/// Theta-tree formulation the literature uses for the full algorithm. Fine at the task-list sizes
273/// scheduling constraints see in this crate's benchmark corpus; a production-scale (100s of
274/// tasks) implementation would want the Theta-tree instead.
275/// Space: O(N).
276///
277/// # Reference
278/// Erschler, J., & Lopez, P. (1990). *Energy-based approaches for task scheduling under time and
279/// resource constraints*. Baptiste, P., Le Pape, C., & Nuijten, W. (2001). *Constraint-Based
280/// Scheduling*. Springer (edge-finding and energetic reasoning for unary/cumulative resources).
281pub(crate) fn energetic_overload(tasks: &[(i64, i64, i64)], capacity: u32) -> bool {
282    let capacity = i64::from(capacity);
283    for &(a, _, _) in tasks {
284        for &(_, b, _) in tasks {
285            if a >= b {
286                continue;
287            }
288            let window = b - a;
289            let energy_sum = tasks
290                .iter()
291                .filter(|&&(est, lct, _)| est >= a && lct <= b)
292                .fold(0i64, |acc, &(_, _, energy)| acc.saturating_add(energy));
293            if energy_sum > capacity.saturating_mul(window) {
294                return true;
295            }
296        }
297    }
298    false
299}