Skip to main content

pounce_presolve/
diagnostics.rs

1//! Diagnostics for the auxiliary-equality preprocessing pass (Phase 0).
2//!
3//! Populated across PRs 1, 8, and 9 of the auxiliary-presolve port
4//! (issue #53). PR 9 expanded the struct with per-stage timings,
5//! per-coupling-class counts, and a human-readable `Display` impl
6//! so users can pipe `wrapped.auxiliary_diagnostics()` straight to
7//! a log line.
8
9use std::fmt;
10
11use pounce_common::types::{Index, Number};
12
13/// Reasons the orchestrator may decline to eliminate a candidate block.
14///
15/// PR 1 wires the enum so it can live in the diagnostics struct; the
16/// populating logic lands with PR 5 (coupling classification) and PR 6
17/// (block solve).
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AuxiliaryRejectionReason {
20    /// Block too large for the lightweight Newton solver and no IPM
21    /// fallback installed (PR 11).
22    BlockTooLarge,
23    /// Block is coupled to inequality rows or the objective in a way
24    /// the current coupling policy disallows.
25    CouplingDisallowed,
26    /// Newton diverged or hit `presolve_auxiliary_max_iter`.
27    BlockSolveDiverged,
28    /// Full-space KKT residual after the candidate reduction exceeded
29    /// `presolve_auxiliary_tol`.
30    ResidualCheckFailed,
31    /// PR #60 review nit: Newton's solution to the block landed
32    /// outside the variable box. The orchestrator declines to clamp
33    /// `x_l = x_u = solution` at an out-of-bounds value.
34    OutOfBounds,
35    /// C2 soundness gate: one of the block's rows references a
36    /// non-block variable that is neither trivially fixed
37    /// (`x_l == x_u`) nor pinned by an earlier accepted block, so the
38    /// IPM is still free to move it. Eliminating the block would fold
39    /// that variable into the RHS at its probe value and silently make
40    /// the dropped equality conditional on a value that can change.
41    NonBlockColumnFree,
42}
43
44/// Per-stage wall-time breakdown for one Phase-0 pass.
45#[derive(Debug, Clone, Default, Copy)]
46pub struct StageTimings {
47    pub incidence_ms: u128,
48    pub matching_ms: u128,
49    pub dm_ms: u128,
50    pub components_ms: u128,
51    pub btf_ms: u128,
52    pub block_solve_ms: u128,
53    pub residual_check_ms: u128,
54}
55
56/// Count of candidate blocks broken down by
57/// [`crate::coupling::AuxiliaryCouplingClass`].
58#[derive(Debug, Clone, Default, Copy)]
59pub struct ClassCounts {
60    pub pure_equality: Index,
61    pub objective_coupled: Index,
62    pub inequality_coupled: Index,
63    pub objective_and_inequality_coupled: Index,
64}
65
66/// Per-run summary of what the auxiliary-equality preprocessing pass
67/// did. All counters are zeroed by [`Default::default`].
68///
69/// # Example
70///
71/// ```
72/// use pounce_presolve::diagnostics::AuxiliaryPreprocessingDiagnostics;
73///
74/// let d = AuxiliaryPreprocessingDiagnostics::default();
75/// assert_eq!(d.blocks_eliminated, 0);
76/// assert_eq!(d.candidate_blocks, 0);
77/// assert!(d.rejection_reasons.is_empty());
78/// ```
79#[derive(Debug, Clone, Default)]
80pub struct AuxiliaryPreprocessingDiagnostics {
81    /// Number of blocks the orchestrator successfully eliminated.
82    pub blocks_eliminated: Index,
83    /// Total candidate blocks examined (eliminated + rejected).
84    pub candidate_blocks: Index,
85    /// Variables fixed by the eliminated blocks (sum of block dims).
86    pub vars_eliminated: Index,
87    /// Equality rows dropped from the reduced problem.
88    pub rows_eliminated: Index,
89    /// Total wall time spent inside Phase 0, in milliseconds.
90    pub total_time_ms: u128,
91    /// Per-stage wall-time breakdown.
92    pub stage_time_ms: StageTimings,
93    /// Per-coupling-class candidate counts.
94    pub class_counts: ClassCounts,
95    /// Largest block-solve residual accepted under
96    /// `presolve_auxiliary_tol`. `0.0` when nothing was eliminated.
97    pub max_block_residual: Number,
98    /// Dimension of the largest accepted block.
99    pub max_accepted_block_dim: Index,
100    /// One entry per rejected candidate, in encounter order.
101    pub rejection_reasons: Vec<AuxiliaryRejectionReason>,
102    /// PR 13: count of variables the trivial-elimination pre-pass
103    /// identified as already fixed (`x_l[i] == x_u[i]`). Excluded
104    /// from the incidence graph.
105    pub trivially_fixed_vars: Index,
106    /// PR 13: count of rows the trivial pre-pass identified as
107    /// "free" (`g_l <= -big && g_u >= +big`). Excluded.
108    pub trivially_free_rows: Index,
109    /// PR 13: count of linear inequality rows already implied by
110    /// the variable box. Excluded from the inequality incidence so
111    /// they don't trip coupling classification.
112    pub trivially_slack_rows: Index,
113    /// PR 14: count of `InequalityCoupled` candidate blocks the
114    /// orchestrator admitted because every coupled inequality
115    /// turned out to be implied by the variable box after
116    /// projecting the block's linear equality system.
117    pub inequality_coupled_accepted_via_projection: Index,
118}
119
120impl fmt::Display for AuxiliaryPreprocessingDiagnostics {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        writeln!(
123            f,
124            "auxiliary-preprocessing: {} of {} candidate block(s) eliminated, \
125             fixing {} variable(s) and dropping {} row(s) in {} ms",
126            self.blocks_eliminated,
127            self.candidate_blocks,
128            self.vars_eliminated,
129            self.rows_eliminated,
130            self.total_time_ms,
131        )?;
132        if self.blocks_eliminated > 0 {
133            writeln!(
134                f,
135                "  max block dim: {}, max residual: {:.3e}",
136                self.max_accepted_block_dim, self.max_block_residual
137            )?;
138        }
139        let cc = &self.class_counts;
140        if cc.pure_equality
141            + cc.objective_coupled
142            + cc.inequality_coupled
143            + cc.objective_and_inequality_coupled
144            > 0
145        {
146            writeln!(
147                f,
148                "  candidates by coupling class: pure={}, obj={}, ineq={}, both={}",
149                cc.pure_equality,
150                cc.objective_coupled,
151                cc.inequality_coupled,
152                cc.objective_and_inequality_coupled,
153            )?;
154        }
155        if !self.rejection_reasons.is_empty() {
156            writeln!(f, "  rejections ({}):", self.rejection_reasons.len())?;
157            // Tally by reason.
158            let mut by_reason: std::collections::BTreeMap<&str, usize> =
159                std::collections::BTreeMap::new();
160            for r in &self.rejection_reasons {
161                let key = match r {
162                    AuxiliaryRejectionReason::BlockTooLarge => "block-too-large",
163                    AuxiliaryRejectionReason::CouplingDisallowed => "coupling-disallowed",
164                    AuxiliaryRejectionReason::BlockSolveDiverged => "block-solve-diverged",
165                    AuxiliaryRejectionReason::ResidualCheckFailed => "residual-check-failed",
166                    AuxiliaryRejectionReason::OutOfBounds => "out-of-bounds",
167                    AuxiliaryRejectionReason::NonBlockColumnFree => "non-block-column-free",
168                };
169                *by_reason.entry(key).or_insert(0) += 1;
170            }
171            for (reason, count) in by_reason {
172                writeln!(f, "    {reason}: {count}")?;
173            }
174        }
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn diagnostics_default_is_empty() {
185        let d = AuxiliaryPreprocessingDiagnostics::default();
186        assert_eq!(d.blocks_eliminated, 0);
187        assert_eq!(d.candidate_blocks, 0);
188        assert_eq!(d.vars_eliminated, 0);
189        assert_eq!(d.rows_eliminated, 0);
190        assert_eq!(d.total_time_ms, 0);
191        assert_eq!(d.max_block_residual, 0.0);
192        assert_eq!(d.max_accepted_block_dim, 0);
193        assert_eq!(d.stage_time_ms.matching_ms, 0);
194        assert_eq!(d.class_counts.pure_equality, 0);
195        assert!(d.rejection_reasons.is_empty());
196    }
197
198    #[test]
199    fn display_empty_diagnostics() {
200        let d = AuxiliaryPreprocessingDiagnostics::default();
201        let s = format!("{d}");
202        assert!(s.contains("0 of 0 candidate block(s) eliminated"));
203        // No rejections or class lines when everything is zero.
204        assert!(!s.contains("rejections"));
205        assert!(!s.contains("coupling"));
206    }
207
208    #[test]
209    fn display_populated_diagnostics() {
210        let mut d = AuxiliaryPreprocessingDiagnostics {
211            blocks_eliminated: 2,
212            candidate_blocks: 3,
213            vars_eliminated: 4,
214            rows_eliminated: 4,
215            total_time_ms: 12,
216            max_block_residual: 1.5e-13,
217            max_accepted_block_dim: 2,
218            ..Default::default()
219        };
220        d.class_counts.pure_equality = 2;
221        d.class_counts.inequality_coupled = 1;
222        d.rejection_reasons
223            .push(AuxiliaryRejectionReason::CouplingDisallowed);
224        let s = format!("{d}");
225        assert!(s.contains("2 of 3 candidate block(s) eliminated"));
226        assert!(s.contains("max block dim: 2"));
227        assert!(s.contains("max residual: 1.500e-13"));
228        assert!(s.contains("candidates by coupling class"));
229        assert!(s.contains("pure=2"));
230        assert!(s.contains("ineq=1"));
231        assert!(s.contains("coupling-disallowed: 1"));
232    }
233}