Skip to main content

feral_ordering_core/quotient_graph/
metric.rs

1//! Selection-metric trait abstracting AMD vs AMF differences.
2//!
3//! The shared quotient-graph machinery (`Workspace`, `select_pivot`,
4//! `create_element`, `finalize_step`, hash-based supervariable
5//! detection, mass elimination, aggressive absorption) is identical
6//! across AMD and AMF. The metrics differ in:
7//!
8//! 1. **Initial score** seeded from each row's adjacency length.
9//!    AMD: identity. AMF: identity (both = `len`).
10//! 2. **Bucket array length.** AMD: `n` (degrees `0..n` indexable;
11//!    `select_pivot` scans `while deg < n`). AMF: `2 * n + 2` because
12//!    the quantized RMF can exceed `n`.
13//! 3. **Bucket index for a score.** AMD: identity. AMF: identity for
14//!    `s ≤ n`, then coarse stride `PAS = max(n / 8, 1)` above.
15//! 4. **Pivot selection within a bucket.** AMD: head only.
16//!    AMF: linear scan when the bucket is in the coarse region.
17//! 5. **Score on supervariable merge.** AMD: no-op (only `nv[i]`
18//!    accumulates). AMF: `score[i] = max(score[i], score[j])`.
19//! 6. **Score finalisation** at the end of Pass-2: AMD's loose-degree
20//!    formula `min(deg_prev, scan2_deg) + degme - nvi` clamped at
21//!    `nleft - nvi`; AMF's quantized RMF (Amestoy 1999 thesis).
22//!
23//! The trait below covers (1)–(5). Site (6) is metric-specific in the
24//! Pass-2 inner loop and is reached via the `run_elimination`
25//! dispatch — each metric impl wires its own concrete loop in
26//! `algo.rs` (AMD: `run_elimination`, AMF: `run_elimination_amf`).
27//! The trait stays light: keeping the inner loops as parallel
28//! concrete functions trades ~300 LoC duplication for zero risk to
29//! the AMD bit-parity contract.
30//!
31//! Reference: `dev/research/amf-clean-room.md` Section 6.
32
33use super::algo::{run_elimination as run_elimination_amd, run_elimination_amf, StepFlops};
34use super::workspace::Workspace;
35use crate::OrderingError;
36
37/// Selection metric for an AMD-family bottom-up ordering.
38///
39/// All methods are zero-overhead `#[inline(always)]` no-ops or
40/// identity functions in the AMD case; AMF (`MinFill`) provides
41/// non-trivial implementations. The trait is consumed at the
42/// `run_elimination` dispatch point and at the bucket-allocation
43/// dispatch in [`crate::quotient_graph::order`]; the metric-specific
44/// inner-loop sites are inlined into the concrete `run_elimination_*`
45/// functions in `algo.rs`.
46pub trait Metric {
47    /// Bucket key produced by the selection metric. AMD uses `i32`
48    /// (the running degree); AMF will also use `i32` (quantized RMF).
49    type Score: Copy + Ord + Default;
50
51    /// Length of the bucket head array `Workspace::head`. AMD: `n`
52    /// (indexed up to `n - 1` by `select_pivot`'s `while deg < n`).
53    /// AMF: `2 * n + 2`.
54    fn n_buckets(n: usize) -> usize;
55
56    /// Initial score for a freshly-loaded variable with adjacency
57    /// length `len`. AMD and AMF both seed `len`.
58    fn init_score(len: i32) -> Self::Score;
59
60    /// Bucket index for the given score. AMD: identity. AMF: identity
61    /// for `s ≤ n`, coarse-stride above.
62    fn bucket(score: Self::Score, n: usize) -> usize;
63
64    /// Whether `idx` falls in the "coarse" bucket region — i.e.
65    /// `select_pivot` must linear-scan the bucket chain to pick the
66    /// minimum-score entry, rather than just taking the head. AMD
67    /// always returns `false`; AMF returns `idx > n`.
68    fn coarse_bucket(idx: usize, n: usize) -> bool;
69
70    /// Update `parent`'s score on supervariable merge of `child` into
71    /// `parent`. AMD: no-op. AMF: `*parent = max(*parent, child)`.
72    fn merge_supervariable(parent: &mut Self::Score, child: Self::Score);
73
74    /// Run the metric's elimination loop on a freshly initialised
75    /// `Workspace`. Returns the accumulated flop counters.
76    ///
77    /// MinDegree dispatches to `run_elimination` (the AMD-specific
78    /// loop); MinFill dispatches to `run_elimination_amf`.
79    fn run_elimination(ws: &mut Workspace, aggressive: bool) -> Result<StepFlops, OrderingError>;
80}
81
82/// Minimum-degree metric — the AMD selection rule of Amestoy, Davis,
83/// Duff (1996).
84///
85/// Score is the running degree. Bucket index is the score itself.
86/// All buckets are "fine" (head-only pivot selection). Supervariable
87/// merge does not update the score (AMD tracks degree only via
88/// `nv[i]` and the per-iteration Pass-2 monotone cap).
89#[derive(Debug, Clone, Copy, Default)]
90pub struct MinDegree;
91
92impl Metric for MinDegree {
93    type Score = i32;
94
95    #[inline(always)]
96    fn n_buckets(n: usize) -> usize {
97        n
98    }
99
100    #[inline(always)]
101    fn init_score(len: i32) -> i32 {
102        len
103    }
104
105    #[inline(always)]
106    fn bucket(score: i32, _n: usize) -> usize {
107        score as usize
108    }
109
110    #[inline(always)]
111    fn coarse_bucket(_idx: usize, _n: usize) -> bool {
112        false
113    }
114
115    #[inline(always)]
116    fn merge_supervariable(_parent: &mut i32, _child: i32) {
117        // AMD does not maintain a per-supervariable score; degree
118        // bookkeeping flows entirely through `nv[i]` and the
119        // re-insertion loop's loose-degree formula.
120    }
121
122    #[inline(always)]
123    fn run_elimination(ws: &mut Workspace, aggressive: bool) -> Result<StepFlops, OrderingError> {
124        run_elimination_amd(ws, aggressive)
125    }
126}
127
128/// Approximate Minimum Fill metric (HAMF4) — Amestoy 1999 thesis.
129///
130/// AMF selects the next pivot to minimise the *fill* introduced by
131/// the elimination, rather than the candidate's degree. On bipartite-
132/// KKT graphs with a few "hub" rows AMF can be 47× better than AMD on
133/// final `nnz_L` (see `dev/research/amf-clean-room.md` Section 1).
134///
135/// Score is a quantized `RMF = DEG*(DEG-1+2*DEGME) - WF(i)` value
136/// stored in `i32`. Buckets up to and including `NORIG = n` are one
137/// bucket per integer score; above `NORIG` the buckets quantize with
138/// stride `PAS = max(n / 8, 1)` and the head must be linear-scanned
139/// to pick the minimum-RMF entry. Supervariable absorption merges
140/// the per-supervariable WF with `max`.
141///
142/// **Inner loop**: [`MinFill::run_elimination`] dispatches to
143/// `run_elimination_amf` (Phase B.2 of `dev/plans/amf-clean-room.md`).
144/// The lazy WF(e) cache, three-accumulator Pass-2, supervariable
145/// max-merge of `wf`, saturated/regular RMF branch, and coarse-bucket
146/// linear scan all live in `algo.rs`.
147#[derive(Debug, Clone, Copy, Default)]
148pub struct MinFill;
149
150impl Metric for MinFill {
151    type Score = i32;
152
153    /// AMF needs `2 * n + 2` slots: `0..=NORIG` for one-per-score
154    /// fine buckets, `NORIG+1..=NBBUCK` (`NBBUCK = 2 * n`) for
155    /// coarse-stride buckets, and one halo slot at `NBBUCK + 1`
156    /// reserved for V1 boundary variables (inert in our use case;
157    /// see Section 11 of `dev/research/amf-clean-room.md`).
158    #[inline(always)]
159    fn n_buckets(n: usize) -> usize {
160        2 * n + 2
161    }
162
163    /// Seed the per-supervariable score with the row's adjacency
164    /// length `len(i)`. Same seeding as AMD; the AMF metric only
165    /// diverges once the elimination loop starts producing elements.
166    #[inline(always)]
167    fn init_score(len: i32) -> i32 {
168        len
169    }
170
171    /// Quantize `score` into a bucket index.
172    ///
173    /// `0..=n` are fine buckets (one per integer score). Above `n`
174    /// the buckets are coarse with stride `PAS = max(n / 8, 1)`,
175    /// capped at `NBBUCK = 2 * n`. Negative scores (which should not
176    /// occur in a well-formed AMF run, but we defend against
177    /// truncation underflow on `RMF / (NVI + 1)`) clamp to bucket 0.
178    ///
179    /// Reference: `ana_orderings.F:4954-5017` and
180    /// `dev/research/amf-clean-room.md` Section 4.
181    #[inline]
182    fn bucket(score: i32, n: usize) -> usize {
183        if score <= 0 {
184            return 0;
185        }
186        let s = score as usize;
187        if s <= n {
188            return s;
189        }
190        let pas = (n / 8).max(1);
191        let nbbuck = 2 * n;
192        let coarse = (s - n) / pas + n;
193        coarse.min(nbbuck)
194    }
195
196    /// Coarse buckets are those above `NORIG = n`. `select_pivot`
197    /// must walk the bucket chain and pick the entry with the
198    /// smallest *exact* score (`ana_orderings.F:4392-4418`).
199    #[inline(always)]
200    fn coarse_bucket(idx: usize, n: usize) -> bool {
201        idx > n
202    }
203
204    /// On supervariable merge `j → i`, update the surviving anchor's
205    /// score with `max(WF(i), WF(j))` (`ana_orderings.F:4920`).
206    #[inline(always)]
207    fn merge_supervariable(parent: &mut i32, child: i32) {
208        if child > *parent {
209            *parent = child;
210        }
211    }
212
213    fn run_elimination(ws: &mut Workspace, aggressive: bool) -> Result<StepFlops, OrderingError> {
214        run_elimination_amf(ws, aggressive)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn min_degree_n_buckets_matches_workspace_alloc() {
224        // Workspace::new allocates head of length `n`; MinDegree
225        // must agree so the AMD code path indexes the right region.
226        for n in [0usize, 1, 5, 100, 10_000] {
227            assert_eq!(MinDegree::n_buckets(n), n);
228        }
229    }
230
231    #[test]
232    fn min_degree_init_score_is_identity() {
233        for len in [0i32, 1, 17, 1024] {
234            assert_eq!(MinDegree::init_score(len), len);
235        }
236    }
237
238    #[test]
239    fn min_degree_bucket_is_identity() {
240        for s in [0i32, 1, 7, 100] {
241            assert_eq!(MinDegree::bucket(s, 200), s as usize);
242        }
243    }
244
245    #[test]
246    fn min_degree_no_coarse_buckets() {
247        for n in [10usize, 100, 10_000] {
248            for idx in [0usize, 1, n / 2, n - 1] {
249                assert!(!MinDegree::coarse_bucket(idx, n));
250            }
251        }
252    }
253
254    #[test]
255    fn min_degree_merge_does_not_touch_parent() {
256        let mut parent: i32 = 42;
257        MinDegree::merge_supervariable(&mut parent, 7);
258        assert_eq!(parent, 42, "AMD merge is a true no-op on the score");
259    }
260
261    #[test]
262    fn min_fill_n_buckets_is_2n_plus_2() {
263        // NBBUCK = 2*n, plus the +1 head index plus the V1 halo slot.
264        for n in [0usize, 1, 5, 100, 10_000] {
265            assert_eq!(MinFill::n_buckets(n), 2 * n + 2);
266        }
267    }
268
269    #[test]
270    fn min_fill_init_score_is_len() {
271        for len in [0i32, 1, 17, 1024] {
272            assert_eq!(MinFill::init_score(len), len);
273        }
274    }
275
276    /// Fine bucket region: scores `0..=n` map identity.
277    #[test]
278    fn min_fill_bucket_fine_region_is_identity() {
279        let n = 100usize;
280        for s in [0i32, 1, 50, 99, 100] {
281            assert_eq!(MinFill::bucket(s, n), s as usize);
282        }
283    }
284
285    /// Coarse bucket region: scores above `n` quantize with
286    /// stride `PAS = max(n/8, 1)`.
287    #[test]
288    fn min_fill_bucket_coarse_region_quantizes_with_pas() {
289        let n = 100usize;
290        // PAS = 100 / 8 = 12.
291        // bucket(101) = (101 - 100) / 12 + 100 = 0 + 100 = 100.
292        // bucket(112) = (112 - 100) / 12 + 100 = 1 + 100 = 101.
293        // bucket(113) = (113 - 100) / 12 + 100 = 1 + 100 = 101 (same coarse bin).
294        // bucket(124) = (124 - 100) / 12 + 100 = 2 + 100 = 102.
295        assert_eq!(MinFill::bucket(101, n), 100);
296        assert_eq!(MinFill::bucket(112, n), 101);
297        assert_eq!(MinFill::bucket(113, n), 101);
298        assert_eq!(MinFill::bucket(124, n), 102);
299    }
300
301    /// Very large scores cap at `NBBUCK = 2 * n`.
302    #[test]
303    fn min_fill_bucket_caps_at_nbbuck() {
304        let n = 100usize;
305        let nbbuck = 2 * n;
306        // bucket(1_000_000) saturates to NBBUCK.
307        assert_eq!(MinFill::bucket(1_000_000, n), nbbuck);
308        assert_eq!(MinFill::bucket(i32::MAX, n), nbbuck);
309    }
310
311    /// Small `n` falls through to `PAS = 1` so coarse buckets are
312    /// effectively per-integer above `n`.
313    #[test]
314    fn min_fill_bucket_pas_is_at_least_one() {
315        // n = 4, PAS = max(0, 1) = 1.
316        // bucket(5, 4) = (5 - 4) / 1 + 4 = 5.
317        // bucket(6, 4) = (6 - 4) / 1 + 4 = 6.
318        // bucket(8, 4) = (8 - 4) / 1 + 4 = 8 = NBBUCK; cap.
319        // bucket(9, 4) caps at NBBUCK = 8.
320        assert_eq!(MinFill::bucket(5, 4), 5);
321        assert_eq!(MinFill::bucket(6, 4), 6);
322        assert_eq!(MinFill::bucket(8, 4), 8);
323        assert_eq!(MinFill::bucket(9, 4), 8);
324    }
325
326    /// Negative or zero scores clamp to bucket 0 (defensive — the
327    /// AMF math should never produce them after the `RMF / (NVI + 1)`
328    /// division but the saturated-RMF branch can underflow on tiny
329    /// problems).
330    #[test]
331    fn min_fill_bucket_clamps_nonpositive() {
332        assert_eq!(MinFill::bucket(0, 100), 0);
333        assert_eq!(MinFill::bucket(-1, 100), 0);
334        assert_eq!(MinFill::bucket(i32::MIN, 100), 0);
335    }
336
337    /// Coarse bucket region is exactly `idx > n`.
338    #[test]
339    fn min_fill_coarse_bucket_threshold() {
340        let n = 100usize;
341        for idx in [0usize, 50, 99, 100] {
342            assert!(!MinFill::coarse_bucket(idx, n), "{idx} <= n is fine");
343        }
344        for idx in [101usize, 150, 200, 201] {
345            assert!(MinFill::coarse_bucket(idx, n), "{idx} > n is coarse");
346        }
347    }
348
349    /// Supervariable merge takes the max — the larger of the two
350    /// fill estimates becomes the merged score.
351    #[test]
352    fn min_fill_merge_takes_max() {
353        let mut parent: i32 = 10;
354        MinFill::merge_supervariable(&mut parent, 25);
355        assert_eq!(parent, 25, "child larger ⇒ adopt child");
356
357        let mut parent: i32 = 100;
358        MinFill::merge_supervariable(&mut parent, 7);
359        assert_eq!(parent, 100, "child smaller ⇒ keep parent");
360
361        let mut parent: i32 = 42;
362        MinFill::merge_supervariable(&mut parent, 42);
363        assert_eq!(parent, 42, "equal ⇒ unchanged");
364    }
365
366    /// MinFill's elimination now runs the real AMF inner loop on a
367    /// workspace allocated with the AMF bucket count `2 * n + 2`.
368    /// Smoke test on a 2-variable pattern: must succeed and reach
369    /// `nel == n`.
370    #[test]
371    fn min_fill_run_elimination_completes() {
372        use crate::quotient_graph::WorkspaceOptions;
373        use crate::CscPattern;
374        let cp = [0i32, 1, 2];
375        let ri = [0i32, 1];
376        let p = CscPattern::new(2, &cp, &ri).unwrap();
377        let mut ws =
378            Workspace::new_with_n_buckets(&p, &WorkspaceOptions::default(), MinFill::n_buckets(2))
379                .unwrap();
380        MinFill::run_elimination(&mut ws, true).expect("AMF loop runs");
381        assert_eq!(ws.nel, ws.n);
382    }
383}