Skip to main content

csp_solver/domain/
cost_finite.rs

1//! CostFiniteDomain — finite integer domain with parallel per-value cost vector.
2//!
3//! Wraps `FiniteDomain<i32>` with a sorted `(value, cost)` table so that
4//! `CostDomain::cost` is O(log n) via binary search. The cost table is
5//! immutable from construction; removals prune the underlying
6//! `FiniteDomain<i32>` but leave the table untouched so that the surviving
7//! entries keep their original costs across `remove`/`add` cycles.
8//!
9//! # Why `i32` and not `u32`?
10//!
11//! Bipartite-assignment COPs routinely need a "no match" sentinel value
12//! distinct from every valid target index. Using `i32` lets the sentinel
13//! be `-1` — a natural, immediately-recognizable marker — without
14//! consuming a valid index or widening the type. Clients that only need
15//! non-negative values are free to pass them; the domain is agnostic.
16//!
17//! # Typical assignment-COP usage
18//!
19//! ```
20//! use csp_solver::domain::CostFiniteDomain;
21//! use csp_solver::domain::traits::{CostDomain, Domain};
22//!
23//! // Row i of a 3×3 cost matrix, with a −1 sentinel priced at the
24//! // unmatched penalty. Each source row gets its own domain built from
25//! // the corresponding slice of the cost matrix.
26//! let cost_row     = [4.2, 1.1, 7.8];
27//! let unmatch_cost = 100.0;
28//!
29//! let values = vec![-1_i32, 0, 1, 2];
30//! let costs  = vec![unmatch_cost, cost_row[0], cost_row[1], cost_row[2]];
31//! let domain = CostFiniteDomain::new(values, costs);
32//!
33//! assert_eq!(domain.size(), 4);
34//! assert_eq!(domain.cost(&1), 1.1);
35//! assert_eq!(domain.cost(&-1), unmatch_cost);
36//! assert_eq!(domain.min_cost(), 1.1);
37//! ```
38
39use std::cell::Cell;
40
41use super::finite::FiniteDomain;
42use super::traits::{CostDomain, Domain};
43
44/// A finite integer domain paired with a per-value cost vector.
45///
46/// Construction takes `(values, costs)` in any order; the constructor
47/// canonicalizes to ascending value order so that `cost()` can binary
48/// search in O(log n). Removals shrink the active domain but leave the
49/// cost table alone, so values removed during search retain their
50/// original costs if they're added back later.
51///
52/// All `Domain` methods delegate to the wrapped `FiniteDomain<i32>`.
53/// `CostDomain::cost` looks the value up in the sorted cost table;
54/// `CostDomain::min_cost` uses a `Cell`-cached minimum that is
55/// lazily recomputed after any `remove`/`add` mutation — amortized
56/// O(1) for the repeated lower-bound queries the branch-and-bound
57/// solver issues at every search node.
58#[derive(Debug, Clone)]
59pub struct CostFiniteDomain {
60    /// Active values, pruned by the solver during search.
61    inner: FiniteDomain<i32>,
62    /// `(value, cost)` pairs sorted ascending by value. All values
63    /// present in `inner` are also in this table; values removed from
64    /// `inner` may remain here harmlessly because `cost()` is only
65    /// ever queried for live values.
66    costs: Vec<(i32, f64)>,
67    /// Lazily cached minimum cost over the live domain. `None` means
68    /// "dirty — recompute on next `min_cost()` call". Invalidated by
69    /// `remove`/`add`; populated by `min_cost`.
70    min_cost_cache: Cell<Option<f64>>,
71}
72
73impl PartialEq for CostFiniteDomain {
74    fn eq(&self, other: &Self) -> bool {
75        self.inner == other.inner && self.costs == other.costs
76    }
77}
78
79impl CostFiniteDomain {
80    /// Construct a new cost-carrying domain from parallel `values` and
81    /// `costs` slices.
82    ///
83    /// The two inputs are zipped element-wise, then sorted by value so
84    /// that the internal cost table is canonical regardless of input
85    /// order. Duplicate values are not deduplicated — callers are
86    /// responsible for passing a unique value set.
87    ///
88    /// # Panics
89    ///
90    /// Panics if `values.len() != costs.len()`.
91    pub fn new(values: Vec<i32>, costs: Vec<f64>) -> Self {
92        assert_eq!(
93            values.len(),
94            costs.len(),
95            "CostFiniteDomain::new: values and costs length mismatch ({} vs {})",
96            values.len(),
97            costs.len(),
98        );
99        let mut pairs: Vec<(i32, f64)> = values.into_iter().zip(costs).collect();
100        pairs.sort_by_key(|(v, _)| *v);
101        let sorted_values: Vec<i32> = pairs.iter().map(|(v, _)| *v).collect();
102        Self {
103            inner: FiniteDomain::new(sorted_values),
104            costs: pairs,
105            min_cost_cache: Cell::new(None),
106        }
107    }
108}
109
110impl Domain for CostFiniteDomain {
111    type Value = i32;
112
113    fn size(&self) -> usize {
114        self.inner.size()
115    }
116
117    fn contains(&self, val: &i32) -> bool {
118        self.inner.contains(val)
119    }
120
121    fn remove(&mut self, val: &i32) -> bool {
122        let changed = self.inner.remove(val);
123        if changed {
124            self.min_cost_cache.set(None);
125        }
126        changed
127    }
128
129    fn add(&mut self, val: &i32) {
130        self.inner.add(val);
131        self.min_cost_cache.set(None);
132    }
133
134    fn values(&self) -> Vec<i32> {
135        self.inner.values()
136    }
137
138    fn iter(&self) -> impl Iterator<Item = i32> {
139        self.inner.iter()
140    }
141}
142
143impl CostDomain for CostFiniteDomain {
144    /// Lookup cost for `val` via binary search over the sorted table.
145    ///
146    /// Returns `f64::INFINITY` if the value was never registered at
147    /// construction time. This is defensive — the solver only ever
148    /// queries values it read out of the domain, so a miss indicates
149    /// a client contract violation and the infinity acts as a soft
150    /// fence (the branch-and-bound search will reject it).
151    fn cost(&self, val: &i32) -> f64 {
152        match self.costs.binary_search_by(|(v, _)| v.cmp(val)) {
153            Ok(idx) => self.costs[idx].1,
154            Err(_) => f64::INFINITY,
155        }
156    }
157
158    /// Amortized O(1) minimum cost over the live domain.
159    ///
160    /// The result is lazily cached in a `Cell` and invalidated by
161    /// `remove`/`add`. The branch-and-bound solver calls `min_cost()`
162    /// once per unassigned variable per search node, so caching
163    /// eliminates the dominant O(domain) scan at large domain sizes.
164    fn min_cost(&self) -> f64 {
165        if let Some(cached) = self.min_cost_cache.get() {
166            return cached;
167        }
168        let min = self
169            .inner
170            .iter()
171            .map(|v| self.cost(&v))
172            .fold(f64::INFINITY, f64::min);
173        self.min_cost_cache.set(Some(min));
174        min
175    }
176}