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}