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}
36
37/// Per-stage wall-time breakdown for one Phase-0 pass.
38#[derive(Debug, Clone, Default, Copy)]
39pub struct StageTimings {
40    pub incidence_ms: u128,
41    pub matching_ms: u128,
42    pub dm_ms: u128,
43    pub components_ms: u128,
44    pub btf_ms: u128,
45    pub block_solve_ms: u128,
46    pub residual_check_ms: u128,
47}
48
49/// Count of candidate blocks broken down by
50/// [`crate::coupling::AuxiliaryCouplingClass`].
51#[derive(Debug, Clone, Default, Copy)]
52pub struct ClassCounts {
53    pub pure_equality: Index,
54    pub objective_coupled: Index,
55    pub inequality_coupled: Index,
56    pub objective_and_inequality_coupled: Index,
57}
58
59/// Per-run summary of what the auxiliary-equality preprocessing pass
60/// did. All counters are zeroed by [`Default::default`].
61///
62/// # Example
63///
64/// ```
65/// use pounce_presolve::diagnostics::AuxiliaryPreprocessingDiagnostics;
66///
67/// let d = AuxiliaryPreprocessingDiagnostics::default();
68/// assert_eq!(d.blocks_eliminated, 0);
69/// assert_eq!(d.candidate_blocks, 0);
70/// assert!(d.rejection_reasons.is_empty());
71/// ```
72#[derive(Debug, Clone, Default)]
73pub struct AuxiliaryPreprocessingDiagnostics {
74    /// Number of blocks the orchestrator successfully eliminated.
75    pub blocks_eliminated: Index,
76    /// Total candidate blocks examined (eliminated + rejected).
77    pub candidate_blocks: Index,
78    /// Variables fixed by the eliminated blocks (sum of block dims).
79    pub vars_eliminated: Index,
80    /// Equality rows dropped from the reduced problem.
81    pub rows_eliminated: Index,
82    /// Total wall time spent inside Phase 0, in milliseconds.
83    pub total_time_ms: u128,
84    /// Per-stage wall-time breakdown.
85    pub stage_time_ms: StageTimings,
86    /// Per-coupling-class candidate counts.
87    pub class_counts: ClassCounts,
88    /// Largest block-solve residual accepted under
89    /// `presolve_auxiliary_tol`. `0.0` when nothing was eliminated.
90    pub max_block_residual: Number,
91    /// Dimension of the largest accepted block.
92    pub max_accepted_block_dim: Index,
93    /// One entry per rejected candidate, in encounter order.
94    pub rejection_reasons: Vec<AuxiliaryRejectionReason>,
95    /// PR 13: count of variables the trivial-elimination pre-pass
96    /// identified as already fixed (`x_l[i] == x_u[i]`). Excluded
97    /// from the incidence graph.
98    pub trivially_fixed_vars: Index,
99    /// PR 13: count of rows the trivial pre-pass identified as
100    /// "free" (`g_l <= -big && g_u >= +big`). Excluded.
101    pub trivially_free_rows: Index,
102    /// PR 13: count of linear inequality rows already implied by
103    /// the variable box. Excluded from the inequality incidence so
104    /// they don't trip coupling classification.
105    pub trivially_slack_rows: Index,
106    /// PR 14: count of `InequalityCoupled` candidate blocks the
107    /// orchestrator admitted because every coupled inequality
108    /// turned out to be implied by the variable box after
109    /// projecting the block's linear equality system.
110    pub inequality_coupled_accepted_via_projection: Index,
111}
112
113impl fmt::Display for AuxiliaryPreprocessingDiagnostics {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        writeln!(
116            f,
117            "auxiliary-preprocessing: {} of {} candidate block(s) eliminated, \
118             fixing {} variable(s) and dropping {} row(s) in {} ms",
119            self.blocks_eliminated,
120            self.candidate_blocks,
121            self.vars_eliminated,
122            self.rows_eliminated,
123            self.total_time_ms,
124        )?;
125        if self.blocks_eliminated > 0 {
126            writeln!(
127                f,
128                "  max block dim: {}, max residual: {:.3e}",
129                self.max_accepted_block_dim, self.max_block_residual
130            )?;
131        }
132        let cc = &self.class_counts;
133        if cc.pure_equality
134            + cc.objective_coupled
135            + cc.inequality_coupled
136            + cc.objective_and_inequality_coupled
137            > 0
138        {
139            writeln!(
140                f,
141                "  candidates by coupling class: pure={}, obj={}, ineq={}, both={}",
142                cc.pure_equality,
143                cc.objective_coupled,
144                cc.inequality_coupled,
145                cc.objective_and_inequality_coupled,
146            )?;
147        }
148        if !self.rejection_reasons.is_empty() {
149            writeln!(f, "  rejections ({}):", self.rejection_reasons.len())?;
150            // Tally by reason.
151            let mut by_reason: std::collections::BTreeMap<&str, usize> =
152                std::collections::BTreeMap::new();
153            for r in &self.rejection_reasons {
154                let key = match r {
155                    AuxiliaryRejectionReason::BlockTooLarge => "block-too-large",
156                    AuxiliaryRejectionReason::CouplingDisallowed => "coupling-disallowed",
157                    AuxiliaryRejectionReason::BlockSolveDiverged => "block-solve-diverged",
158                    AuxiliaryRejectionReason::ResidualCheckFailed => "residual-check-failed",
159                    AuxiliaryRejectionReason::OutOfBounds => "out-of-bounds",
160                };
161                *by_reason.entry(key).or_insert(0) += 1;
162            }
163            for (reason, count) in by_reason {
164                writeln!(f, "    {reason}: {count}")?;
165            }
166        }
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn diagnostics_default_is_empty() {
177        let d = AuxiliaryPreprocessingDiagnostics::default();
178        assert_eq!(d.blocks_eliminated, 0);
179        assert_eq!(d.candidate_blocks, 0);
180        assert_eq!(d.vars_eliminated, 0);
181        assert_eq!(d.rows_eliminated, 0);
182        assert_eq!(d.total_time_ms, 0);
183        assert_eq!(d.max_block_residual, 0.0);
184        assert_eq!(d.max_accepted_block_dim, 0);
185        assert_eq!(d.stage_time_ms.matching_ms, 0);
186        assert_eq!(d.class_counts.pure_equality, 0);
187        assert!(d.rejection_reasons.is_empty());
188    }
189
190    #[test]
191    fn display_empty_diagnostics() {
192        let d = AuxiliaryPreprocessingDiagnostics::default();
193        let s = format!("{d}");
194        assert!(s.contains("0 of 0 candidate block(s) eliminated"));
195        // No rejections or class lines when everything is zero.
196        assert!(!s.contains("rejections"));
197        assert!(!s.contains("coupling"));
198    }
199
200    #[test]
201    fn display_populated_diagnostics() {
202        let mut d = AuxiliaryPreprocessingDiagnostics {
203            blocks_eliminated: 2,
204            candidate_blocks: 3,
205            vars_eliminated: 4,
206            rows_eliminated: 4,
207            total_time_ms: 12,
208            max_block_residual: 1.5e-13,
209            max_accepted_block_dim: 2,
210            ..Default::default()
211        };
212        d.class_counts.pure_equality = 2;
213        d.class_counts.inequality_coupled = 1;
214        d.rejection_reasons
215            .push(AuxiliaryRejectionReason::CouplingDisallowed);
216        let s = format!("{d}");
217        assert!(s.contains("2 of 3 candidate block(s) eliminated"));
218        assert!(s.contains("max block dim: 2"));
219        assert!(s.contains("max residual: 1.500e-13"));
220        assert!(s.contains("candidates by coupling class"));
221        assert!(s.contains("pure=2"));
222        assert!(s.contains("ineq=1"));
223        assert!(s.contains("coupling-disallowed: 1"));
224    }
225}