Skip to main content

pounce_presolve/
btf.rs

1//! Tarjan SCC + topological order → block-triangular form on each
2//! connected component of the square-matched part.
3//!
4//! PR 4 of the auxiliary-presolve port (issue #53). Given a square
5//! [`crate::components::SquareComponent`] (with a perfect matching
6//! restricted to it), build the **dependency DAG** on its matched
7//! pairs and decompose it into strongly-connected components in
8//! reverse topological order. Each SCC becomes one BTF block:
9//!
10//! - size-1 blocks: variable can be solved on its own once its
11//!   prerequisites are known;
12//! - size-N blocks (N>1): an irreducible cyclic dependency — those
13//!   N rows have to be solved simultaneously as a small system.
14//!
15//! The blocks are returned in **elimination order** — `blocks[0]`
16//! has no in-component dependencies, `blocks[k]` may use values
17//! produced by `blocks[0..k]`.
18//!
19//! ripopt anchor: `src/auxiliary_preprocessing.rs:2473-2552`.
20
21use crate::components::SquareComponent;
22use crate::incidence::EqualityIncidence;
23use crate::matching::BipartiteMatching;
24
25/// One block of the block-triangular form. Rows and cols are
26/// returned sorted ascending for determinism.
27#[derive(Debug, Clone, Default)]
28pub struct BlockTriangularBlock {
29    pub eq_rows: Vec<usize>,
30    pub cols: Vec<usize>,
31}
32
33/// Block-triangular decomposition of one [`SquareComponent`].
34#[derive(Debug, Clone, Default)]
35pub struct BlockTriangularForm {
36    /// Blocks in elimination order: index 0 is solved first.
37    pub blocks: Vec<BlockTriangularBlock>,
38}
39
40// Each `.expect("matched")` below is invariant-protected: rows
41// inside a `SquareComponent` are guaranteed by PR 3 to be matched.
42#[allow(clippy::expect_used)]
43impl BlockTriangularForm {
44    /// Decompose `component` into blocks via Tarjan SCC on the
45    /// dependency DAG induced by `inc` and the matching `m`.
46    ///
47    /// # Example
48    ///
49    /// ```
50    /// use pounce_presolve::incidence::{EqualityIncidence, ProbeView};
51    /// use pounce_presolve::matching::hopcroft_karp;
52    /// use pounce_presolve::dulmage_mendelsohn::DulmageMendelsohnPartition;
53    /// use pounce_presolve::components::SquareComponents;
54    /// use pounce_presolve::btf::BlockTriangularForm;
55    ///
56    /// // 2x2 lower-triangular: row 0 needs col 0; row 1 needs
57    /// // cols 0 and 1. Matching: 0↔0, 1↔1. Two singleton blocks.
58    /// let p = ProbeView {
59    ///     n_vars: 2,
60    ///     m_rows: 2,
61    ///     jac_irow: &[0, 1, 1],
62    ///     jac_jcol: &[0, 0, 1],
63    ///     jac_values: None,
64    ///     g_l: &[0.0; 2],
65    ///     g_u: &[0.0; 2],
66    ///     linearity: None,
67    ///     one_based: false,
68    ///     eq_tol: 1e-12,
69    ///     excluded_vars: None,
70    ///     excluded_rows: None,
71    /// };
72    /// let inc = EqualityIncidence::from_probe(&p);
73    /// let m = hopcroft_karp(&inc);
74    /// let dm = DulmageMendelsohnPartition::from_matching(&inc, &m);
75    /// let comps = SquareComponents::of_square_part(&inc, &m, &dm);
76    /// let btf = BlockTriangularForm::of_component(&inc, &m, &comps.components[0]);
77    /// assert_eq!(btf.blocks.len(), 2);
78    /// assert_eq!(btf.blocks[0].eq_rows, vec![0]);
79    /// assert_eq!(btf.blocks[1].eq_rows, vec![1]);
80    /// ```
81    pub fn of_component(
82        inc: &EqualityIncidence,
83        m: &BipartiteMatching,
84        component: &SquareComponent,
85    ) -> Self {
86        let n = component.eq_rows.len();
87        debug_assert_eq!(
88            n,
89            component.cols.len(),
90            "square component shapes must match"
91        );
92        // PR #60 review nit: explicitly check the `SquareComponent`
93        // invariant — every row in the component must be matched.
94        // This converts a downstream `.expect("matched")` panic
95        // into a clearer assertion at the boundary.
96        debug_assert!(
97            component.eq_rows.iter().all(|&r| m.row_to_var[r].is_some()),
98            "SquareComponent invariant violated: unmatched row in eq_rows"
99        );
100        if n == 0 {
101            return Self::default();
102        }
103
104        // node[i] = matched pair (component.eq_rows[i], matched_col).
105        // We map col → block-node index.
106        let mut col_to_node: std::collections::HashMap<usize, usize> =
107            std::collections::HashMap::with_capacity(n);
108        for (i, &r) in component.eq_rows.iter().enumerate() {
109            let c = m.row_to_var[r].expect("square row must be matched");
110            col_to_node.insert(c, i);
111        }
112
113        // Build dependency adjacency: edge i → j when row at node i
114        // touches a non-matched col owned by node j.
115        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
116        for (i, &r) in component.eq_rows.iter().enumerate() {
117            let own_col = m.row_to_var[r].expect("matched");
118            for &c in inc.neighbors(r) {
119                if c == own_col {
120                    continue;
121                }
122                if let Some(&j) = col_to_node.get(&c) {
123                    if j != i {
124                        adj[i].push(j);
125                    }
126                }
127            }
128            adj[i].sort_unstable();
129            adj[i].dedup();
130        }
131
132        // Tarjan SCC: when our edges have "depends-on" semantics
133        // (i → j means i needs j's value), Tarjan finishes leaves of
134        // the dependency DAG first, so its natural emission order is
135        // already the elimination order — sinks (no remaining deps)
136        // come out first.
137        let sccs = tarjan_scc(&adj);
138
139        let blocks: Vec<BlockTriangularBlock> = sccs
140            .into_iter()
141            .map(|scc| {
142                let mut eq_rows: Vec<usize> = scc.iter().map(|&i| component.eq_rows[i]).collect();
143                let mut cols: Vec<usize> = scc
144                    .iter()
145                    .map(|&i| {
146                        let r = component.eq_rows[i];
147                        m.row_to_var[r].expect("matched")
148                    })
149                    .collect();
150                eq_rows.sort_unstable();
151                cols.sort_unstable();
152                BlockTriangularBlock { eq_rows, cols }
153            })
154            .collect();
155        debug_assert!(blocks.len() <= n);
156        Self { blocks }
157    }
158}
159
160/// Iterative Tarjan SCC. Returns SCCs as lists of node indices.
161/// With edges read as "depends-on", Tarjan finishes leaves (no
162/// outgoing deps) first, so the output is already in elimination
163/// order.
164#[allow(clippy::expect_used)]
165fn tarjan_scc(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
166    let n = adj.len();
167    const UNVISITED: usize = usize::MAX;
168    let mut index = vec![UNVISITED; n];
169    let mut lowlink = vec![0usize; n];
170    let mut on_stack = vec![false; n];
171    let mut stack: Vec<usize> = Vec::new();
172    let mut next_index: usize = 0;
173    let mut sccs: Vec<Vec<usize>> = Vec::new();
174
175    // Explicit DFS stack: each frame is (node, neighbor-iter-pos).
176    for v0 in 0..n {
177        if index[v0] != UNVISITED {
178            continue;
179        }
180        let mut call_stack: Vec<(usize, usize)> = Vec::new();
181        index[v0] = next_index;
182        lowlink[v0] = next_index;
183        next_index += 1;
184        stack.push(v0);
185        on_stack[v0] = true;
186        call_stack.push((v0, 0));
187
188        while let Some(&(v, ref_pos)) = call_stack.last() {
189            let pos = ref_pos;
190            if pos < adj[v].len() {
191                let w = adj[v][pos];
192                // Advance the iterator for this frame before
193                // possibly recursing.
194                call_stack.last_mut().expect("non-empty").1 = pos + 1;
195                if index[w] == UNVISITED {
196                    index[w] = next_index;
197                    lowlink[w] = next_index;
198                    next_index += 1;
199                    stack.push(w);
200                    on_stack[w] = true;
201                    call_stack.push((w, 0));
202                } else if on_stack[w] {
203                    lowlink[v] = lowlink[v].min(index[w]);
204                }
205            } else {
206                // All neighbours processed: pop and update parent.
207                if lowlink[v] == index[v] {
208                    // v is a root of an SCC; pop the stack down to v.
209                    let mut scc = Vec::new();
210                    while let Some(w) = stack.pop() {
211                        on_stack[w] = false;
212                        scc.push(w);
213                        if w == v {
214                            break;
215                        }
216                    }
217                    sccs.push(scc);
218                }
219                call_stack.pop();
220                if let Some(&mut (parent, _)) = call_stack.last_mut() {
221                    lowlink[parent] = lowlink[parent].min(lowlink[v]);
222                }
223            }
224        }
225    }
226
227    sccs
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::components::SquareComponents;
234    use crate::dulmage_mendelsohn::DulmageMendelsohnPartition;
235    use crate::matching::hopcroft_karp;
236
237    fn eq_inc(n_vars: usize, n_rows: usize, edges: &[(usize, usize)]) -> EqualityIncidence {
238        let mut per_row: Vec<Vec<usize>> = vec![Vec::new(); n_rows];
239        for &(r, v) in edges {
240            per_row[r].push(v);
241        }
242        let mut adj_ptr = Vec::with_capacity(n_rows + 1);
243        let mut vars = Vec::new();
244        adj_ptr.push(0);
245        for row in per_row.iter_mut() {
246            row.sort_unstable();
247            row.dedup();
248            vars.extend_from_slice(row);
249            adj_ptr.push(vars.len());
250        }
251        EqualityIncidence {
252            n_vars,
253            eq_row_inner_idx: (0..n_rows).collect(),
254            adj_ptr,
255            vars,
256        }
257    }
258
259    fn btf_of(n_vars: usize, n_rows: usize, edges: &[(usize, usize)]) -> Vec<BlockTriangularForm> {
260        let inc = eq_inc(n_vars, n_rows, edges);
261        let m = hopcroft_karp(&inc);
262        let dm = DulmageMendelsohnPartition::from_matching(&inc, &m);
263        let comps = SquareComponents::of_square_part(&inc, &m, &dm);
264        comps
265            .components
266            .iter()
267            .map(|c| BlockTriangularForm::of_component(&inc, &m, c))
268            .collect()
269    }
270
271    #[test]
272    fn btf_singleton_block() {
273        // 1×1 — one diagonal entry, one trivial block.
274        let btfs = btf_of(1, 1, &[(0, 0)]);
275        assert_eq!(btfs.len(), 1);
276        assert_eq!(btfs[0].blocks.len(), 1);
277        assert_eq!(btfs[0].blocks[0].eq_rows, vec![0]);
278        assert_eq!(btfs[0].blocks[0].cols, vec![0]);
279    }
280
281    #[test]
282    fn btf_chain_lower_triangular() {
283        // Row 0 ↔ col 0 (no off-diag).
284        // Row 1 ↔ col 1, uses col 0.
285        // Row 2 ↔ col 2, uses cols 0 and 1.
286        // Matching: 0↔0, 1↔1, 2↔2. Three size-1 blocks in order
287        // [0], [1], [2].
288        let edges = [(0, 0), (1, 0), (1, 1), (2, 0), (2, 1), (2, 2)];
289        let btfs = btf_of(3, 3, &edges);
290        assert_eq!(btfs.len(), 1);
291        let btf = &btfs[0];
292        assert_eq!(btf.blocks.len(), 3);
293        assert_eq!(btf.blocks[0].eq_rows, vec![0]);
294        assert_eq!(btf.blocks[1].eq_rows, vec![1]);
295        assert_eq!(btf.blocks[2].eq_rows, vec![2]);
296    }
297
298    #[test]
299    fn btf_full_cycle_single_block() {
300        // Row 0 ↔ col 0, uses col 1.
301        // Row 1 ↔ col 1, uses col 2.
302        // Row 2 ↔ col 2, uses col 0.
303        // Dependency graph forms a 3-cycle → one SCC of size 3.
304        let edges = [(0, 0), (0, 1), (1, 1), (1, 2), (2, 2), (2, 0)];
305        let btfs = btf_of(3, 3, &edges);
306        assert_eq!(btfs.len(), 1);
307        let btf = &btfs[0];
308        assert_eq!(btf.blocks.len(), 1);
309        assert_eq!(btf.blocks[0].eq_rows, vec![0, 1, 2]);
310        assert_eq!(btf.blocks[0].cols, vec![0, 1, 2]);
311    }
312
313    #[test]
314    fn btf_two_subcycles_chained() {
315        // Two 2-cycles, the second depending on the first:
316        //   {0,1} form a 2-cycle on cols {0,1}.
317        //   {2,3} form a 2-cycle on cols {2,3}.
318        //   Row 2 also uses col 0 → dependency on first block.
319        // Matching: 0↔0, 1↔1, 2↔2, 3↔3.
320        let edges = [
321            (0, 0),
322            (0, 1),
323            (1, 1),
324            (1, 0),
325            (2, 2),
326            (2, 3),
327            (3, 3),
328            (3, 2),
329            (2, 0), // bridge: pair {2,3} depends on pair {0,1}
330        ];
331        let btfs = btf_of(4, 4, &edges);
332        assert_eq!(btfs.len(), 1);
333        let btf = &btfs[0];
334        assert_eq!(btf.blocks.len(), 2, "two size-2 SCCs");
335        assert_eq!(btf.blocks[0].eq_rows, vec![0, 1]);
336        assert_eq!(btf.blocks[1].eq_rows, vec![2, 3]);
337    }
338
339    #[test]
340    fn btf_empty_component() {
341        let inc = eq_inc(0, 0, &[]);
342        let m = hopcroft_karp(&inc);
343        let comp = SquareComponent {
344            eq_rows: vec![],
345            cols: vec![],
346        };
347        let btf = BlockTriangularForm::of_component(&inc, &m, &comp);
348        assert!(btf.blocks.is_empty());
349    }
350
351    #[test]
352    fn btf_elimination_order_respects_dependencies() {
353        // 5×5 single component:
354        //   row 0 ↔ col 0 (no off-diag)
355        //   row 1 ↔ col 1, uses col 0
356        //   row 2 ↔ col 2, uses col 1
357        //   rows 3, 4 form a 2-cycle on cols 3, 4, and one of them
358        //   bridges into col 2 so the whole thing is one component.
359        let edges = [
360            (0, 0),
361            (1, 1),
362            (1, 0),
363            (2, 2),
364            (2, 1),
365            (3, 3),
366            (3, 4),
367            (3, 2), // bridge into the {0,1,2} chain
368            (4, 4),
369            (4, 3),
370        ];
371        let btfs = btf_of(5, 5, &edges);
372        assert_eq!(btfs.len(), 1);
373        let btf = &btfs[0];
374        // Build a map from variable to the block index that owns it.
375        let mut col_block = std::collections::HashMap::new();
376        for (b_idx, block) in btf.blocks.iter().enumerate() {
377            for &c in &block.cols {
378                col_block.insert(c, b_idx);
379            }
380        }
381        // For every block k and every row r in it, every column it
382        // touches outside its own block must belong to a strictly
383        // earlier block.
384        let inc = eq_inc(5, 5, &edges);
385        for (k, block) in btf.blocks.iter().enumerate() {
386            for &r in &block.eq_rows {
387                for &c in inc.neighbors(r) {
388                    if block.cols.contains(&c) {
389                        continue;
390                    }
391                    let owner = *col_block.get(&c).expect("col owned by some block");
392                    assert!(owner < k, "block {k} uses col {c} from later block {owner}");
393                }
394            }
395        }
396        // The bridging row 3 puts the {3,4} 2-cycle strictly after
397        // the [0],[1],[2] chain. Expected order: 3 singletons + one
398        // size-2 cycle = 4 blocks.
399        assert_eq!(btf.blocks.len(), 4);
400        assert_eq!(btf.blocks[0].eq_rows, vec![0]);
401        assert_eq!(btf.blocks[1].eq_rows, vec![1]);
402        assert_eq!(btf.blocks[2].eq_rows, vec![2]);
403        assert_eq!(btf.blocks[3].eq_rows, vec![3, 4]);
404    }
405
406    #[test]
407    fn btf_self_loop_singleton() {
408        // 1×1 where the only edge is (0, 0) — node 0 has no
409        // outgoing edges in the dependency graph. Single block.
410        let btfs = btf_of(1, 1, &[(0, 0)]);
411        assert_eq!(btfs.len(), 1);
412        assert_eq!(btfs[0].blocks.len(), 1);
413    }
414
415    #[test]
416    fn btf_three_disjoint_singletons() {
417        let edges = [(0, 0), (1, 1), (2, 2)];
418        let btfs = btf_of(3, 3, &edges);
419        // Three components, each one size-1 BTF block.
420        assert_eq!(btfs.len(), 3);
421        for btf in &btfs {
422            assert_eq!(btf.blocks.len(), 1);
423        }
424    }
425
426    /// Independent SCC reference via Floyd-Warshall transitive
427    /// closure. For each pair `(i, j)`, two nodes share an SCC iff
428    /// they reach each other. Buckets nodes by their (sorted-rep)
429    /// reach-mate set; one bucket per SCC. Returns the SCCs in the
430    /// elimination order our BTF promises — a block must appear
431    /// strictly before any block it depends on, ties broken by
432    /// smallest node id.
433    fn reference_sccs_in_elim_order(adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
434        let n = adj.len();
435        // reach[i][j] = "is there a non-empty path i ⇒ j (length ≥ 1)?"
436        let mut reach = vec![vec![false; n]; n];
437        for (i, row) in reach.iter_mut().enumerate().take(n) {
438            for &j in &adj[i] {
439                row[j] = true;
440            }
441        }
442        // Floyd-Warshall transitive closure.
443        for k in 0..n {
444            for i in 0..n {
445                if reach[i][k] {
446                    for j in 0..n {
447                        if reach[k][j] {
448                            reach[i][j] = true;
449                        }
450                    }
451                }
452            }
453        }
454        // Pair nodes that mutually reach each other (or coincide).
455        let mut comp_of = vec![usize::MAX; n];
456        let mut comps: Vec<Vec<usize>> = Vec::new();
457        for i in 0..n {
458            if comp_of[i] != usize::MAX {
459                continue;
460            }
461            let id = comps.len();
462            let mut bucket = vec![i];
463            comp_of[i] = id;
464            for j in (i + 1)..n {
465                let mutual = (reach[i][j] || i == j) && (reach[j][i] || i == j);
466                if mutual {
467                    bucket.push(j);
468                    comp_of[j] = id;
469                }
470            }
471            bucket.sort_unstable();
472            comps.push(bucket);
473        }
474        // Build the condensation DAG and topologically sort.
475        let n_c = comps.len();
476        let mut c_adj: Vec<std::collections::BTreeSet<usize>> =
477            vec![std::collections::BTreeSet::new(); n_c];
478        let mut indeg = vec![0usize; n_c];
479        for (i, row) in adj.iter().enumerate().take(n) {
480            for &j in row {
481                let ci = comp_of[i];
482                let cj = comp_of[j];
483                if ci != cj && c_adj[ci].insert(cj) {
484                    indeg[cj] += 1;
485                }
486            }
487        }
488        // Kahn's algorithm — but elimination order means SINKS come
489        // first (a node with no outgoing edges in the condensation
490        // has nothing to depend on, so it solves first). So we run
491        // Kahn on the reverse graph.
492        let mut rev_indeg = vec![0usize; n_c];
493        for adj_set in &c_adj {
494            for &j in adj_set {
495                rev_indeg[j] += 1;
496            }
497        }
498        // Out-degree in condensation = "depends on N others".
499        // Sinks have out-degree 0.
500        let mut out_deg = vec![0usize; n_c];
501        for (i, adj_set) in c_adj.iter().enumerate().take(n_c) {
502            out_deg[i] = adj_set.len();
503            let _ = i; // silence unused-var lints in odd builds
504        }
505        let mut queue: std::collections::BTreeSet<usize> =
506            (0..n_c).filter(|&i| out_deg[i] == 0).collect();
507        let mut order: Vec<Vec<usize>> = Vec::with_capacity(n_c);
508        // Reverse the condensation so we can pop-by-incoming-edge.
509        let mut rev_adj: Vec<Vec<usize>> = vec![Vec::new(); n_c];
510        for (i, adj_set) in c_adj.iter().enumerate().take(n_c) {
511            for &j in adj_set {
512                rev_adj[j].push(i);
513            }
514        }
515        let _ = rev_indeg;
516        while let Some(&c) = queue.iter().next() {
517            queue.remove(&c);
518            order.push(comps[c].clone());
519            for &pred in &rev_adj[c] {
520                out_deg[pred] -= 1;
521                if out_deg[pred] == 0 {
522                    queue.insert(pred);
523                }
524            }
525        }
526        assert_eq!(order.len(), n_c, "topological sort lost an SCC");
527        order
528    }
529
530    /// Fuzz against the Floyd-Warshall SCC reference. For each
531    /// component of 30 random graphs we check:
532    ///   - same number of blocks as the reference;
533    ///   - blocks contain the same node sets in the same order;
534    ///   - elimination-order invariant: every cross-block reference
535    ///     points to an earlier block;
536    ///   - sum of block sizes equals component size.
537    #[test]
538    fn btf_fuzz_invariants() {
539        let mut state: u64 = 0x1234_5678_9abc_def0;
540        let mut next = || -> u64 {
541            state = state
542                .wrapping_mul(6364136223846793005)
543                .wrapping_add(1442695040888963407);
544            state >> 32
545        };
546
547        for trial in 0..30 {
548            let n_rows = 1 + (next() % 4) as usize;
549            let n_vars = 1 + (next() % 4) as usize;
550            let max_edges = (n_rows * n_vars).min(8);
551            let n_edges = (next() % (max_edges as u64 + 1)) as usize;
552
553            let mut edge_set = std::collections::BTreeSet::<(usize, usize)>::new();
554            let mut draws = 0usize;
555            while edge_set.len() < n_edges {
556                let r = (next() % n_rows as u64) as usize;
557                let v = (next() % n_vars as u64) as usize;
558                edge_set.insert((r, v));
559                draws += 1;
560                assert!(draws < 10_000);
561            }
562            let edges: Vec<(usize, usize)> = edge_set.into_iter().collect();
563
564            let inc = eq_inc(n_vars, n_rows, &edges);
565            let m = hopcroft_karp(&inc);
566            let dm = DulmageMendelsohnPartition::from_matching(&inc, &m);
567            let comps = SquareComponents::of_square_part(&inc, &m, &dm);
568
569            for (ci, comp) in comps.components.iter().enumerate() {
570                let our_btf = BlockTriangularForm::of_component(&inc, &m, comp);
571                let n = comp.eq_rows.len();
572
573                // Build the same dependency adjacency BTF uses, in
574                // local-node space (0..n), for the reference impl.
575                let mut col_to_node: std::collections::HashMap<usize, usize> =
576                    std::collections::HashMap::with_capacity(n);
577                for (i, &r) in comp.eq_rows.iter().enumerate() {
578                    let c = m.row_to_var[r].expect("matched");
579                    col_to_node.insert(c, i);
580                }
581                let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
582                for (i, &r) in comp.eq_rows.iter().enumerate() {
583                    let own_col = m.row_to_var[r].expect("matched");
584                    for &c in inc.neighbors(r) {
585                        if c == own_col {
586                            continue;
587                        }
588                        if let Some(&j) = col_to_node.get(&c) {
589                            if j != i {
590                                adj[i].push(j);
591                            }
592                        }
593                    }
594                    adj[i].sort_unstable();
595                    adj[i].dedup();
596                }
597
598                let ref_sccs = reference_sccs_in_elim_order(&adj);
599
600                // Same number of blocks.
601                assert_eq!(
602                    our_btf.blocks.len(),
603                    ref_sccs.len(),
604                    "trial {trial} comp {ci}: block count differs (ours={}, ref={})",
605                    our_btf.blocks.len(),
606                    ref_sccs.len(),
607                );
608
609                // Same node sets per block. We compare sets of node
610                // indices (0..n in component-local space); ties in
611                // SCC ordering can break either way but the SCC
612                // PARTITIONS should be identical.
613                for (bi, (ours, theirs)) in our_btf.blocks.iter().zip(ref_sccs.iter()).enumerate() {
614                    let ours_nodes: std::collections::BTreeSet<usize> = ours
615                        .eq_rows
616                        .iter()
617                        .map(|r| {
618                            comp.eq_rows
619                                .iter()
620                                .position(|x| x == r)
621                                .expect("row in component")
622                        })
623                        .collect();
624                    let theirs_nodes: std::collections::BTreeSet<usize> =
625                        theirs.iter().copied().collect();
626                    assert_eq!(
627                        ours_nodes, theirs_nodes,
628                        "trial {trial} comp {ci} block {bi}: \
629                         node sets differ (ours={:?}, ref={:?})",
630                        ours_nodes, theirs_nodes
631                    );
632                }
633
634                // Sum of block sizes equals component size.
635                let sum: usize = our_btf.blocks.iter().map(|b| b.eq_rows.len()).sum();
636                assert_eq!(sum, n, "trial {trial} comp {ci}: block sizes don't add up");
637
638                // Elimination order: every cross-block ref is earlier.
639                let mut col_block = std::collections::HashMap::new();
640                for (b_idx, block) in our_btf.blocks.iter().enumerate() {
641                    for &c in &block.cols {
642                        col_block.insert(c, b_idx);
643                    }
644                }
645                for (k, block) in our_btf.blocks.iter().enumerate() {
646                    for &r in &block.eq_rows {
647                        for &c in inc.neighbors(r) {
648                            if !col_block.contains_key(&c) {
649                                continue; // col is outside this component
650                            }
651                            if block.cols.contains(&c) {
652                                continue;
653                            }
654                            let owner = col_block[&c];
655                            assert!(
656                                owner < k,
657                                "trial {trial} comp {ci}: block {k} uses col {c} \
658                                 from later block {owner}"
659                            );
660                        }
661                    }
662                }
663            }
664        }
665    }
666}