Skip to main content

logicaffeine_proof/
matching.rs

1//! Certified bipartite-matching infeasibility — the polynomial reasoner for pigeonhole-shaped
2//! problems that are *exponential* for resolution (and therefore for any CDCL SAT solver, ours
3//! included).
4//!
5//! Many infeasibility claims are really "n items must each take a distinct slot, but they only
6//! reach m < n slots" — graph colouring of a clique (n mutually-adjacent movements need n phases),
7//! the pigeonhole principle, exam scheduling, register allocation. Encoded as boolean SAT these are
8//! pigeonhole instances, which need exponentially long resolution refutations. But the underlying
9//! question — does a system of "each slot holds at most one item" constraints admit an assignment
10//! of every item? — is just **bipartite maximum matching**, decided in polynomial time.
11//!
12//! [`assign_or_hall`] returns either a feasible assignment (a checkable witness of feasibility) or a
13//! **Hall witness**: a set `S` of items whose combined reachable slots `T` satisfy `|T| < |S|`, so
14//! the items cannot be placed (a checkable witness of *in*feasibility, à la a clique or an odd
15//! cycle). Both outcomes are independently re-verifiable — [`is_hall_witness`] and a feasibility
16//! check — so this is a *certified* decision, never a trusted solver verdict.
17
18/// The outcome of a bipartite "each slot holds at most one item" feasibility check.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum MatchOutcome {
21    /// Every item placed: `assignment[i]` is the slot item `i` takes (all distinct).
22    Feasible(Vec<usize>),
23    /// No assignment exists, witnessed by a deficient set of items.
24    Infeasible(HallWitness),
25}
26
27/// A Hall-theorem certificate of infeasibility: `items` (the set `S`) can collectively reach only
28/// the slots in `slots` (a superset of `N(S)`), and `slots.len() < items.len()` — so by pigeonhole
29/// the items cannot be placed one-per-slot. Independently checkable via [`is_hall_witness`].
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct HallWitness {
32    /// The deficient item set `S`.
33    pub items: Vec<usize>,
34    /// Slots covering `N(S)`, with `slots.len() < items.len()`.
35    pub slots: Vec<usize>,
36}
37
38/// Decide whether every item can be assigned a distinct slot, where `adj[i]` lists the slots item
39/// `i` may use and slots range over `0..num_slots`. Returns a perfect assignment or a certified
40/// Hall witness. Finds a **maximum** matching with **Hopcroft–Karp** (`O(E·√V)` — many shortest
41/// vertex-disjoint augmenting paths per phase, far faster than Kuhn's `O(V·E)` as instances grow),
42/// then — on failure — the König alternating-reachability construction extracts the deficient set.
43pub fn assign_or_hall(adj: &[Vec<usize>], num_slots: usize) -> MatchOutcome {
44    let n = adj.len();
45    // Counting fast-path: more items than slots is infeasible by pigeonhole alone — each slot holds
46    // at most one item, so `n` items demand `n` distinct slots and only `num_slots < n` exist. No
47    // matching needed; the whole item set against all slots is the Hall witness. O(n) instead of
48    // O(E·√V), so pure pigeonhole (the common case) is decided in microseconds at any scale.
49    if n > num_slots {
50        return MatchOutcome::Infeasible(HallWitness {
51            items: (0..n).collect(),
52            slots: (0..num_slots).collect(),
53        });
54    }
55    let mut slot_match: Vec<Option<usize>> = vec![None; num_slots]; // slot -> item
56    let mut item_match: Vec<Option<usize>> = vec![None; n]; // item -> slot
57    hopcroft_karp(adj, &mut slot_match, &mut item_match);
58    if item_match.iter().all(|m| m.is_some()) {
59        return MatchOutcome::Feasible(item_match.into_iter().map(|m| m.unwrap()).collect());
60    }
61    MatchOutcome::Infeasible(extract_hall(adj, num_slots, &slot_match, &item_match))
62}
63
64/// Hopcroft–Karp maximum bipartite matching. Each phase: a BFS layers the items by shortest
65/// alternating-path distance toward a free slot, then DFS augments along a maximal set of
66/// vertex-disjoint shortest paths — `O(√V)` phases of `O(E)`.
67fn hopcroft_karp(
68    adj: &[Vec<usize>],
69    slot_match: &mut [Option<usize>],
70    item_match: &mut [Option<usize>],
71) {
72    let n = adj.len();
73    let num_slots = slot_match.len();
74    const INF: usize = usize::MAX;
75    loop {
76        // BFS over items: dist[u] is u's layer; a phase exists iff a free slot becomes reachable.
77        let mut dist = vec![INF; n];
78        let mut queue = std::collections::VecDeque::new();
79        for u in 0..n {
80            if item_match[u].is_none() {
81                dist[u] = 0;
82                queue.push_back(u);
83            }
84        }
85        let mut reachable_free = false;
86        while let Some(u) = queue.pop_front() {
87            for &v in &adj[u] {
88                if v >= num_slots {
89                    continue;
90                }
91                match slot_match[v] {
92                    None => reachable_free = true,
93                    Some(w) if dist[w] == INF => {
94                        dist[w] = dist[u] + 1;
95                        queue.push_back(w);
96                    }
97                    _ => {}
98                }
99            }
100        }
101        if !reachable_free {
102            break; // no augmenting path remains ⇒ the matching is maximum
103        }
104        for u in 0..n {
105            if item_match[u].is_none() {
106                hk_dfs(u, adj, slot_match, item_match, &mut dist);
107            }
108        }
109    }
110}
111
112/// One Hopcroft–Karp augmenting DFS, restricted to the BFS layering (`dist`). Dead ends are marked
113/// `INF` so other DFS calls in the same phase skip them, keeping the augmenting paths disjoint.
114fn hk_dfs(
115    u: usize,
116    adj: &[Vec<usize>],
117    slot_match: &mut [Option<usize>],
118    item_match: &mut [Option<usize>],
119    dist: &mut [usize],
120) -> bool {
121    let num_slots = slot_match.len();
122    for idx in 0..adj[u].len() {
123        let v = adj[u][idx];
124        if v >= num_slots {
125            continue;
126        }
127        let proceed = match slot_match[v] {
128            None => true,
129            Some(w) => dist[w] == dist[u] + 1 && hk_dfs(w, adj, slot_match, item_match, dist),
130        };
131        if proceed {
132            slot_match[v] = Some(u);
133            item_match[u] = Some(v);
134            return true;
135        }
136    }
137    dist[u] = usize::MAX;
138    false
139}
140
141/// König construction: alternating reachability from the unmatched items. Every reachable slot is
142/// matched (else an augmenting path would exist, contradicting maximality), and its matched item is
143/// reachable — so the reachable items outnumber the reachable slots by exactly the unmatched count,
144/// and every slot adjacent to a reachable item is itself reachable. Hence `S = reachable items`,
145/// `T = reachable slots` is a Hall witness with `N(S) ⊆ T` and `|T| < |S|`.
146fn extract_hall(
147    adj: &[Vec<usize>],
148    num_slots: usize,
149    slot_match: &[Option<usize>],
150    item_match: &[Option<usize>],
151) -> HallWitness {
152    let n = adj.len();
153    let mut item_reach = vec![false; n];
154    let mut slot_reach = vec![false; num_slots];
155    let mut stack: Vec<usize> = Vec::new();
156    for (u, m) in item_match.iter().enumerate() {
157        if m.is_none() {
158            item_reach[u] = true;
159            stack.push(u);
160        }
161    }
162    while let Some(u) = stack.pop() {
163        for &v in &adj[u] {
164            if v < num_slots && !slot_reach[v] {
165                slot_reach[v] = true;
166                if let Some(w) = slot_match[v] {
167                    if !item_reach[w] {
168                        item_reach[w] = true;
169                        stack.push(w);
170                    }
171                }
172            }
173        }
174    }
175    HallWitness {
176        items: (0..n).filter(|&u| item_reach[u]).collect(),
177        slots: (0..num_slots).filter(|&v| slot_reach[v]).collect(),
178    }
179}
180
181/// Independently re-check a Hall witness: every item in `S` reaches only slots in `T`, and
182/// `|T| < |S|`. This is the certificate verifier — a trusted, solver-free check that the claimed
183/// infeasibility is genuine.
184pub fn is_hall_witness(adj: &[Vec<usize>], w: &HallWitness) -> bool {
185    if w.items.len() <= w.slots.len() {
186        return false;
187    }
188    let slot_set: std::collections::HashSet<usize> = w.slots.iter().copied().collect();
189    w.items.iter().all(|&i| {
190        i < adj.len() && adj[i].iter().all(|s| slot_set.contains(s))
191    })
192}
193
194/// Re-check a feasible assignment: one distinct slot per item, each within that item's allowed set.
195pub fn is_valid_assignment(adj: &[Vec<usize>], num_slots: usize, assignment: &[usize]) -> bool {
196    if assignment.len() != adj.len() {
197        return false;
198    }
199    let mut used = vec![false; num_slots];
200    assignment.iter().enumerate().all(|(i, &s)| {
201        let ok = s < num_slots && adj[i].contains(&s) && !used[s];
202        if s < num_slots {
203            used[s] = true;
204        }
205        ok
206    })
207}
208
209// ── Capacitated b-matching (each slot holds up to a capacity) ────────────────
210
211/// The outcome of a capacitated assignment: each slot `s` holds at most `capacities[s]` items.
212#[derive(Clone, Debug, PartialEq, Eq)]
213pub enum CapMatchOutcome {
214    /// Every item placed: `assignment[i]` is the slot item `i` takes (respecting capacities).
215    Feasible(Vec<usize>),
216    /// No assignment exists, witnessed by a capacity-deficient item set.
217    Infeasible(CapHallWitness),
218}
219
220/// A capacitated Hall certificate: the items in `S` can only reach the slots in `slots`, whose
221/// *total capacity* is strictly less than `|S|` — so they cannot all be placed. Re-checkable via
222/// [`is_cap_hall_witness`].
223#[derive(Clone, Debug, PartialEq, Eq)]
224pub struct CapHallWitness {
225    /// The deficient item set `S`.
226    pub items: Vec<usize>,
227    /// Slots covering `N(S)`, with `Σ capacities[s] < |S|`.
228    pub slots: Vec<usize>,
229}
230
231/// Decide whether every item can be assigned a slot when slot `s` holds at most `capacities[s]`
232/// items (a b-matching / resource-allocation feasibility — e.g. traffic movements sharing
233/// capacity-limited green windows). Reduces to plain matching by splitting each slot into that many
234/// interchangeable copies, then maps the result (and any Hall witness) back to original slots.
235pub fn assign_or_hall_capacitated(adj: &[Vec<usize>], capacities: &[usize]) -> CapMatchOutcome {
236    let num_slots = capacities.len();
237    let mut offset = vec![0usize; num_slots + 1];
238    for s in 0..num_slots {
239        offset[s + 1] = offset[s] + capacities[s];
240    }
241    let total = offset[num_slots];
242    // Each item, adjacent to slot s, is adjacent to all of slot s's copies.
243    let exp_adj: Vec<Vec<usize>> = adj
244        .iter()
245        .map(|slots| {
246            slots
247                .iter()
248                .filter(|&&s| s < num_slots)
249                .flat_map(|&s| offset[s]..offset[s + 1])
250                .collect()
251        })
252        .collect();
253    let copy_to_slot = |c: usize| offset.partition_point(|&o| o <= c) - 1;
254    match assign_or_hall(&exp_adj, total) {
255        MatchOutcome::Feasible(copy_assign) => {
256            CapMatchOutcome::Feasible(copy_assign.into_iter().map(copy_to_slot).collect())
257        }
258        MatchOutcome::Infeasible(w) => {
259            // Reachable copies are exactly all copies of the reachable original slots, so their
260            // total capacity equals |w.slots| < |w.items| — the capacitated Hall deficiency.
261            let mut slots: Vec<usize> = w.slots.into_iter().map(copy_to_slot).collect();
262            slots.sort_unstable();
263            slots.dedup();
264            CapMatchOutcome::Infeasible(CapHallWitness { items: w.items, slots })
265        }
266    }
267}
268
269/// Re-check a capacitated Hall witness: every item in `S` reaches only slots in `T`, and the total
270/// capacity of `T` is below `|S|`.
271pub fn is_cap_hall_witness(adj: &[Vec<usize>], capacities: &[usize], w: &CapHallWitness) -> bool {
272    let cap: usize = w.slots.iter().map(|&s| capacities.get(s).copied().unwrap_or(0)).sum();
273    if w.items.len() <= cap {
274        return false;
275    }
276    let slot_set: std::collections::HashSet<usize> = w.slots.iter().copied().collect();
277    w.items
278        .iter()
279        .all(|&i| i < adj.len() && adj[i].iter().all(|s| slot_set.contains(s)))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// Pigeonhole PHP(n): n items each able to use any of n-1 slots — infeasible, and the witness
287    /// is the whole item set against all n-1 slots.
288    fn php(n: usize) -> (Vec<Vec<usize>>, usize) {
289        let holes = n - 1;
290        ((0..n).map(|_| (0..holes).collect()).collect(), holes)
291    }
292
293    #[test]
294    fn pigeonhole_is_infeasible_with_a_genuine_hall_witness() {
295        for n in 2..=12 {
296            let (adj, slots) = php(n);
297            match assign_or_hall(&adj, slots) {
298                MatchOutcome::Infeasible(w) => {
299                    assert!(is_hall_witness(&adj, &w), "PHP({n}) witness invalid: {w:?}");
300                    assert_eq!(w.items.len(), n, "all {n} pigeons are deficient");
301                    assert_eq!(w.slots.len(), n - 1, "against {} holes", n - 1);
302                }
303                other => panic!("PHP({n}) must be infeasible, got {other:?}"),
304            }
305        }
306    }
307
308    #[test]
309    fn equal_items_and_slots_is_feasible() {
310        // n items, n slots, complete bipartite → a perfect matching exists.
311        for n in 1..=10 {
312            let adj: Vec<Vec<usize>> = (0..n).map(|_| (0..n).collect()).collect();
313            match assign_or_hall(&adj, n) {
314                MatchOutcome::Feasible(a) => {
315                    assert!(is_valid_assignment(&adj, n, &a), "invalid assignment {a:?}");
316                }
317                other => panic!("n={n} square should be feasible, got {other:?}"),
318            }
319        }
320    }
321
322    #[test]
323    fn restricted_subset_triggers_hall() {
324        // 3 items all confined to slots {0,1} (out of 4 slots) → infeasible: 3 items, 2 reachable.
325        let adj = vec![vec![0, 1], vec![0, 1], vec![0, 1], vec![2, 3]];
326        match assign_or_hall(&adj, 4) {
327            MatchOutcome::Infeasible(w) => {
328                assert!(is_hall_witness(&adj, &w), "witness invalid: {w:?}");
329                assert!(w.items.len() > w.slots.len());
330            }
331            other => panic!("expected Hall violation, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn a_solvable_restricted_matching_is_feasible() {
337        // A perfect matching exists (0->0, 1->1, 2->2, 3->3) despite restrictions.
338        let adj = vec![vec![0, 1], vec![1, 2], vec![2, 3], vec![3, 0]];
339        match assign_or_hall(&adj, 4) {
340            MatchOutcome::Feasible(a) => assert!(is_valid_assignment(&adj, 4, &a)),
341            other => panic!("expected a feasible matching, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn empty_is_trivially_feasible() {
347        assert_eq!(assign_or_hall(&[], 0), MatchOutcome::Feasible(vec![]));
348    }
349
350    #[test]
351    fn a_bad_hall_witness_is_rejected() {
352        // |S| not > |T| ⇒ not a witness; and an item reaching outside T ⇒ not a witness.
353        let adj = vec![vec![0, 1], vec![0, 1]];
354        assert!(!is_hall_witness(&adj, &HallWitness { items: vec![0], slots: vec![0, 1] }));
355        assert!(!is_hall_witness(&adj, &HallWitness { items: vec![0, 1], slots: vec![0] }),
356            "item 1 reaches slot 1 ∉ T, so {{0}} cannot cover N(S)");
357    }
358
359    #[test]
360    fn capacity_makes_overloaded_slots_infeasible() {
361        // 5 movements all want the one green window: capacity 5 fits, capacity 4 does not.
362        let adj = vec![vec![0], vec![0], vec![0], vec![0], vec![0]];
363        assert!(matches!(assign_or_hall_capacitated(&adj, &[5]), CapMatchOutcome::Feasible(_)));
364        match assign_or_hall_capacitated(&adj, &[4]) {
365            CapMatchOutcome::Infeasible(w) => {
366                assert!(is_cap_hall_witness(&adj, &[4], &w), "cap witness invalid: {w:?}");
367                assert_eq!(w.items.len(), 5);
368                assert_eq!(w.slots, vec![0]);
369            }
370            o => panic!("capacity 4 must be infeasible: {o:?}"),
371        }
372    }
373
374    #[test]
375    fn capacitated_assignment_respects_capacities() {
376        // 4 items over 2 slots of capacity 2 → feasible, each slot used ≤ 2.
377        let adj: Vec<Vec<usize>> = (0..4).map(|_| vec![0, 1]).collect();
378        match assign_or_hall_capacitated(&adj, &[2, 2]) {
379            CapMatchOutcome::Feasible(a) => {
380                assert_eq!(a.len(), 4);
381                assert!(a.iter().filter(|&&s| s == 0).count() <= 2);
382                assert!(a.iter().filter(|&&s| s == 1).count() <= 2);
383                assert!(a.iter().all(|&s| s == 0 || s == 1));
384            }
385            o => panic!("should be feasible: {o:?}"),
386        }
387        // 5 items, total capacity 4 → infeasible with a certified capacity-deficiency.
388        let adj5: Vec<Vec<usize>> = (0..5).map(|_| vec![0, 1]).collect();
389        match assign_or_hall_capacitated(&adj5, &[2, 2]) {
390            CapMatchOutcome::Infeasible(w) => assert!(is_cap_hall_witness(&adj5, &[2, 2], &w)),
391            o => panic!("5 items / capacity 4 must be infeasible: {o:?}"),
392        }
393    }
394
395    #[test]
396    fn hopcroft_karp_finds_the_maximum_matching() {
397        // Independent Kuhn reference for the maximum-matching size.
398        fn kuhn_size(adj: &[Vec<usize>], num_slots: usize) -> usize {
399            fn aug(u: usize, adj: &[Vec<usize>], sm: &mut [Option<usize>], seen: &mut [bool]) -> bool {
400                for &v in &adj[u] {
401                    if v >= seen.len() || seen[v] {
402                        continue;
403                    }
404                    seen[v] = true;
405                    if sm[v].is_none() || aug(sm[v].unwrap(), adj, sm, seen) {
406                        sm[v] = Some(u);
407                        return true;
408                    }
409                }
410                false
411            }
412            let mut sm = vec![None; num_slots];
413            let mut count = 0;
414            for u in 0..adj.len() {
415                let mut seen = vec![false; num_slots];
416                if aug(u, adj, &mut sm, &mut seen) {
417                    count += 1;
418                }
419            }
420            count
421        }
422        // Deterministic xorshift corpus of random bipartite graphs.
423        let mut s: u64 = 0x9E3779B97F4A7C15;
424        let mut next = || {
425            s ^= s << 13;
426            s ^= s >> 7;
427            s ^= s << 17;
428            s
429        };
430        for _ in 0..300 {
431            let n = (next() % 9) as usize + 1;
432            let num_slots = (next() % 9) as usize + 1;
433            let adj: Vec<Vec<usize>> = (0..n)
434                .map(|_| (0..num_slots).filter(|_| next() % 2 == 0).collect())
435                .collect();
436            let kuhn = kuhn_size(&adj, num_slots);
437            let outcome = assign_or_hall(&adj, num_slots);
438            // Hopcroft–Karp must agree on feasibility (perfect matching ⟺ max matching = n)…
439            let feasible = matches!(outcome, MatchOutcome::Feasible(_));
440            assert_eq!(
441                feasible,
442                kuhn == n,
443                "HK/Kuhn disagree: adj={adj:?} slots={num_slots} hk_feasible={feasible} kuhn={kuhn} n={n}"
444            );
445            // …and every returned witness re-checks.
446            match outcome {
447                MatchOutcome::Feasible(a) => assert!(is_valid_assignment(&adj, num_slots, &a)),
448                MatchOutcome::Infeasible(w) => assert!(is_hall_witness(&adj, &w)),
449            }
450        }
451    }
452}