Skip to main content

xlog_cuda/
joint_solver.rs

1//! Joint constraint solver — skeleton: deterministic component
2//! decomposition, pinned-width envelope, and typed fuel accounting.
3//!
4//! Decomposition, width bounding, and strategy selection are
5//! cold-path setup over the constraint graph. Solve execution
6//! (feasibility propagation, exact top-two/max-marginal dynamic
7//! programming, branch-and-bound) is device-resident and lands with
8//! the solve slice; nothing in this module emits solver outputs, so
9//! no host path here can become a solving fallback.
10
11/// Identity of the solver ABI and objective this module implements:
12/// deterministic component decomposition, exact top-two/max-marginal
13/// DP inside the pinned width envelope, exact branch-and-bound
14/// within device fuel, typed exhaustion beyond it. Carrier schemas
15/// and calibration artifacts bind to this identity; it changes
16/// whenever the ABI or objective changes.
17pub const SOLVER_ABI_IDENTITY: &str = "joint-solver/decompose-dp-bb/1";
18
19/// Typed solver errors. Beyond fuel the solve refuses with the
20/// exact spent/limit literals — no partial emission, no
21/// approximation, no host fallback.
22#[derive(Debug, PartialEq, Eq)]
23pub enum SolverError {
24    /// The device fuel budget is exhausted.
25    ResourceExhausted { fuel_spent: u64, fuel_limit: u64 },
26}
27
28impl std::fmt::Display for SolverError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            SolverError::ResourceExhausted {
32                fuel_spent,
33                fuel_limit,
34            } => write!(
35                f,
36                "solver fuel exhausted: spent {fuel_spent} of {fuel_limit} node expansions"
37            ),
38        }
39    }
40}
41
42impl std::error::Error for SolverError {}
43
44/// Saturating feasibility count for a component: none, exactly one,
45/// or many satisfying assignments. Deliberately separate from score
46/// ambiguity — a component can be uniquely feasible with an
47/// ambiguous maximum, or plurally feasible with a unique maximum.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Feasibility {
50    None,
51    One,
52    Many,
53}
54
55/// Solve strategy for one component, selected by the width bound
56/// against the pinned envelope: exact dynamic programming inside the
57/// envelope, exact branch-and-bound (within fuel) outside it.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum SolveStrategy {
60    ExactDp,
61    BranchAndBound,
62}
63
64/// One connected component of the constraint graph, in canonical
65/// form: variables ascending, edges normalized (low, high) and
66/// sorted, plus a deterministic elimination-order width bound.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Component {
69    pub variables: Vec<u32>,
70    pub edges: Vec<(u32, u32)>,
71    pub width_bound: u32,
72}
73
74impl Component {
75    /// Strategy under a pinned width envelope. The bound is an
76    /// upper bound, so `ExactDp` selection is safe: true width can
77    /// only be smaller.
78    pub fn strategy(&self, pinned_width: u32) -> SolveStrategy {
79        if self.width_bound <= pinned_width {
80            SolveStrategy::ExactDp
81        } else {
82            SolveStrategy::BranchAndBound
83        }
84    }
85}
86
87/// Undirected constraint graph over entity variables. Self-loops
88/// are meaningless for binary constraints and rejected at
89/// construction; unconnected variables still form singleton
90/// components so no variable can silently drop out of the solve.
91pub struct ConstraintGraph {
92    num_variables: u32,
93    edges: Vec<(u32, u32)>,
94}
95
96impl ConstraintGraph {
97    pub fn new(num_variables: u32, edges: impl IntoIterator<Item = (u32, u32)>) -> Self {
98        let edges: Vec<(u32, u32)> = edges
99            .into_iter()
100            .map(|(a, b)| {
101                assert!(
102                    a < num_variables && b < num_variables,
103                    "constraint edge ({a}, {b}) references a variable outside 0..{num_variables}"
104                );
105                assert!(a != b, "self-loop constraint edge on variable {a}");
106                (a.min(b), a.max(b))
107            })
108            .collect();
109        Self {
110            num_variables,
111            edges,
112        }
113    }
114
115    /// Deterministic connected-component decomposition: union-find
116    /// over the edges, components ordered by their minimum variable
117    /// index, members ascending, edges normalized and sorted. The
118    /// same graph decomposes identically regardless of edge input
119    /// order.
120    pub fn decompose(&self) -> Vec<Component> {
121        let n = self.num_variables as usize;
122        let mut parent: Vec<u32> = (0..self.num_variables).collect();
123
124        fn find(parent: &mut [u32], x: u32) -> u32 {
125            let mut root = x;
126            while parent[root as usize] != root {
127                root = parent[root as usize];
128            }
129            let mut cur = x;
130            while parent[cur as usize] != root {
131                let next = parent[cur as usize];
132                parent[cur as usize] = root;
133                cur = next;
134            }
135            root
136        }
137
138        for &(a, b) in &self.edges {
139            let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
140            if ra != rb {
141                // Deterministic union: smaller root wins, so every
142                // component's representative is its minimum variable.
143                let (lo, hi) = (ra.min(rb), ra.max(rb));
144                parent[hi as usize] = lo;
145            }
146        }
147
148        let mut members: Vec<Vec<u32>> = vec![Vec::new(); n];
149        for v in 0..self.num_variables {
150            let root = find(&mut parent, v);
151            members[root as usize].push(v);
152        }
153        let mut component_edges: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
154        for &(a, b) in &self.edges {
155            let root = find(&mut parent, a);
156            component_edges[root as usize].push((a, b));
157        }
158
159        (0..n)
160            .filter(|&root| !members[root].is_empty())
161            .map(|root| {
162                let variables = members[root].clone();
163                let mut edges = component_edges[root].clone();
164                edges.sort_unstable();
165                edges.dedup();
166                let width_bound = elimination_width_bound(&variables, &edges);
167                Component {
168                    variables,
169                    edges,
170                    width_bound,
171                }
172            })
173            .collect()
174    }
175}
176
177/// Deterministic upper bound on the component's treewidth via
178/// min-degree elimination (ties broken by variable index). An upper
179/// bound is the safe direction for strategy selection: it can send
180/// a narrow component to branch-and-bound, never a wide one to DP.
181fn elimination_width_bound(variables: &[u32], edges: &[(u32, u32)]) -> u32 {
182    use std::collections::{BTreeMap, BTreeSet};
183
184    let mut adj: BTreeMap<u32, BTreeSet<u32>> =
185        variables.iter().map(|&v| (v, BTreeSet::new())).collect();
186    for &(a, b) in edges {
187        adj.get_mut(&a).unwrap().insert(b);
188        adj.get_mut(&b).unwrap().insert(a);
189    }
190
191    let mut width = 0u32;
192    while !adj.is_empty() {
193        // Min degree, then min index: fully deterministic.
194        let (&v, _) = adj
195            .iter()
196            .min_by_key(|(idx, neigh)| (neigh.len(), **idx))
197            .expect("non-empty adjacency");
198        let neighbors: Vec<u32> = adj[&v].iter().copied().collect();
199        width = width.max(neighbors.len() as u32);
200        for &n in &neighbors {
201            let set = adj.get_mut(&n).expect("neighbor present");
202            set.remove(&v);
203            for &m in &neighbors {
204                if m != n {
205                    set.insert(m);
206                }
207            }
208        }
209        adj.remove(&v);
210    }
211    width
212}
213
214/// Group candidate rows into connectivity components: two candidates
215/// share a component when their entity pairs intersect. Returns CSR
216/// form `(offsets, indices)`, components ordered by minimum candidate
217/// index, members ascending — deterministic regardless of pair input
218/// order. This is host-side setup over the producer's OWN host-side
219/// pair list (the producer constructs the pairs before writing them
220/// to the device), never a device readback.
221pub fn candidate_components(num_entities: u32, pairs: &[(u32, u32)]) -> (Vec<u32>, Vec<u32>) {
222    let n = pairs.len();
223    let mut parent: Vec<u32> = (0..n as u32).collect();
224
225    fn find(parent: &mut [u32], x: u32) -> u32 {
226        let mut root = x;
227        while parent[root as usize] != root {
228            root = parent[root as usize];
229        }
230        let mut cur = x;
231        while parent[cur as usize] != root {
232            let next = parent[cur as usize];
233            parent[cur as usize] = root;
234            cur = next;
235        }
236        root
237    }
238
239    let mut entity_owner: Vec<Option<u32>> = vec![None; num_entities as usize];
240    for (i, &(head, tail)) in pairs.iter().enumerate() {
241        for entity in [head, tail] {
242            assert!(
243                entity < num_entities,
244                "candidate {i} references entity {entity} outside 0..{num_entities}"
245            );
246            match entity_owner[entity as usize] {
247                None => entity_owner[entity as usize] = Some(i as u32),
248                Some(owner) => {
249                    let (ra, rb) = (find(&mut parent, i as u32), find(&mut parent, owner));
250                    if ra != rb {
251                        let (lo, hi) = (ra.min(rb), ra.max(rb));
252                        parent[hi as usize] = lo;
253                    }
254                }
255            }
256        }
257    }
258
259    let mut members: Vec<Vec<u32>> = vec![Vec::new(); n];
260    for cand in 0..n as u32 {
261        let root = find(&mut parent, cand);
262        members[root as usize].push(cand);
263    }
264    let mut offsets = Vec::new();
265    let mut indices = Vec::new();
266    offsets.push(0u32);
267    for group in members.into_iter().filter(|g| !g.is_empty()) {
268        indices.extend_from_slice(&group);
269        offsets.push(indices.len() as u32);
270    }
271    (offsets, indices)
272}
273
274/// Fuel accounting for node expansions. The production counter is
275/// device-resident and read back once post-solve as bounded
276/// metadata; this meter is the typed refusal seam both sides share.
277/// Exhaustion saturates: once refused, every further charge refuses
278/// with the same literals, so no caller can slip work past the
279/// budget by retrying.
280#[derive(Debug)]
281pub struct FuelMeter {
282    limit: u64,
283    spent: u64,
284}
285
286impl FuelMeter {
287    pub fn new(limit: u64) -> Self {
288        Self { limit, spent: 0 }
289    }
290
291    pub fn spent(&self) -> u64 {
292        self.spent
293    }
294
295    /// Unspent budget.
296    pub fn remaining(&self) -> u64 {
297        self.limit - self.spent
298    }
299
300    /// Refund expansions that a prior authorization charged but the
301    /// device measurably did not spend. Callers refund at most
302    /// `authorized - measured` for one completed solve; the meter
303    /// saturates at zero rather than underflowing.
304    pub fn refund(&mut self, expansions: u64) {
305        self.spent = self.spent.saturating_sub(expansions);
306    }
307
308    /// Charge `expansions` node expansions. Refuses typed the
309    /// moment the budget would be exceeded; the overflowing charge
310    /// is not applied.
311    pub fn charge(&mut self, expansions: u64) -> Result<(), SolverError> {
312        let new_spent = self.spent.saturating_add(expansions);
313        if new_spent > self.limit {
314            return Err(SolverError::ResourceExhausted {
315                fuel_spent: self.spent,
316                fuel_limit: self.limit,
317            });
318        }
319        self.spent = new_spent;
320        Ok(())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn decomposition_is_deterministic_under_edge_order() {
330        let edges = [(3, 1), (7, 5), (1, 0), (5, 6)];
331        let mut reversed = edges;
332        reversed.reverse();
333        let a = ConstraintGraph::new(9, edges).decompose();
334        let b = ConstraintGraph::new(9, reversed).decompose();
335        assert_eq!(a, b, "edge input order must not change the decomposition");
336    }
337
338    #[test]
339    fn every_variable_lands_in_exactly_one_component() {
340        let graph = ConstraintGraph::new(6, [(0, 1), (4, 5)]);
341        let components = graph.decompose();
342        let mut seen: Vec<u32> = components
343            .iter()
344            .flat_map(|c| c.variables.iter().copied())
345            .collect();
346        seen.sort_unstable();
347        assert_eq!(seen, vec![0, 1, 2, 3, 4, 5]);
348        // Isolated variables 2 and 3 are singleton components, not
349        // silently dropped from the solve.
350        assert_eq!(components.len(), 4);
351        assert!(components
352            .iter()
353            .any(|c| c.variables == vec![2] && c.edges.is_empty()));
354    }
355
356    #[test]
357    fn components_are_canonical_and_ordered_by_minimum_variable() {
358        let graph = ConstraintGraph::new(7, [(6, 4), (2, 0), (4, 5)]);
359        let components = graph.decompose();
360        let mins: Vec<u32> = components.iter().map(|c| c.variables[0]).collect();
361        let mut sorted = mins.clone();
362        sorted.sort_unstable();
363        assert_eq!(mins, sorted, "components ordered by minimum variable");
364        for c in &components {
365            let mut vars = c.variables.clone();
366            vars.sort_unstable();
367            assert_eq!(vars, c.variables, "variables ascending");
368            let mut edges = c.edges.clone();
369            edges.sort_unstable();
370            assert_eq!(edges, c.edges, "edges normalized and sorted");
371            assert!(c.edges.iter().all(|(a, b)| a < b), "edges are (low, high)");
372        }
373    }
374
375    #[test]
376    fn width_bound_matches_known_graphs() {
377        // Path 0-1-2-3: treewidth 1.
378        let path = ConstraintGraph::new(4, [(0, 1), (1, 2), (2, 3)]).decompose();
379        assert_eq!(path[0].width_bound, 1);
380        // Star center 0: treewidth 1.
381        let star = ConstraintGraph::new(5, [(0, 1), (0, 2), (0, 3), (0, 4)]).decompose();
382        assert_eq!(star[0].width_bound, 1);
383        // Complete graph K4: treewidth 3.
384        let k4 =
385            ConstraintGraph::new(4, [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]).decompose();
386        assert_eq!(k4[0].width_bound, 3);
387    }
388
389    #[test]
390    fn strategy_splits_on_the_pinned_envelope() {
391        let k4 =
392            ConstraintGraph::new(4, [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]).decompose();
393        assert_eq!(k4[0].strategy(3), SolveStrategy::ExactDp);
394        assert_eq!(k4[0].strategy(2), SolveStrategy::BranchAndBound);
395    }
396
397    #[test]
398    fn candidate_components_join_on_shared_entities_deterministically() {
399        // Candidates 0,1 share entity 1; candidate 2 is disjoint.
400        let pairs = [(0, 1), (1, 2), (3, 4)];
401        let (offsets, indices) = candidate_components(5, &pairs);
402        assert_eq!(offsets, vec![0, 2, 3]);
403        assert_eq!(indices, vec![0, 1, 2]);
404
405        // Same graph with pairs listed in reverse candidate roles:
406        // the grouping is identical because membership is by shared
407        // entity, not by input order of the pair fields.
408        let flipped = [(1, 0), (2, 1), (4, 3)];
409        let (offsets_f, indices_f) = candidate_components(5, &flipped);
410        assert_eq!((offsets_f, indices_f), (offsets, indices));
411
412        // Every candidate lands exactly once.
413        let (offsets, indices) = candidate_components(3, &[(0, 1), (1, 2), (0, 2)]);
414        assert_eq!(offsets, vec![0, 3]);
415        let mut sorted = indices.clone();
416        sorted.sort_unstable();
417        assert_eq!(sorted, vec![0, 1, 2]);
418    }
419
420    #[test]
421    fn fuel_refuses_typed_at_the_boundary_and_saturates() {
422        let mut fuel = FuelMeter::new(10);
423        fuel.charge(10).expect("exactly the budget is legal");
424        let err = fuel.charge(1).expect_err("beyond fuel must refuse");
425        assert_eq!(
426            err,
427            SolverError::ResourceExhausted {
428                fuel_spent: 10,
429                fuel_limit: 10
430            }
431        );
432        // The refused charge was not applied, and refusal repeats
433        // with identical literals — no retry can slip work through.
434        assert_eq!(fuel.spent(), 10);
435        assert_eq!(
436            fuel.charge(1).expect_err("still refused"),
437            SolverError::ResourceExhausted {
438                fuel_spent: 10,
439                fuel_limit: 10
440            }
441        );
442    }
443}