use std::collections::HashSet;
use std::fmt::{Display, Formatter, Result as FmtResult};
use crate::interner::NameId;
use crate::model::{ComparisonOp, Constraint, SOSType, VariableType};
use crate::problem::LpProblem;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone)]
pub struct AnalysisConfig {
pub large_coefficient_threshold: f64,
pub small_coefficient_threshold: f64,
pub large_rhs_threshold: f64,
pub coefficient_ratio_threshold: f64,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
large_coefficient_threshold: 1e9,
small_coefficient_threshold: 1e-9,
large_rhs_threshold: 1e9,
coefficient_ratio_threshold: 1e6,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct ProblemAnalysis {
pub summary: ProblemSummary,
pub sparsity: SparsityMetrics,
pub variables: VariableAnalysis,
pub constraints: ConstraintAnalysis,
pub coefficients: CoefficientAnalysis,
pub issues: Vec<AnalysisIssue>,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct ProblemSummary {
pub name: Option<String>,
pub sense: String,
pub objective_count: usize,
pub constraint_count: usize,
pub variable_count: usize,
pub total_nonzeros: usize,
pub density: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct SparsityMetrics {
pub min_vars_per_constraint: usize,
pub max_vars_per_constraint: usize,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct VariableAnalysis {
pub type_distribution: VariableTypeDistribution,
pub free_variables: Vec<String>,
pub fixed_variables: Vec<FixedVariable>,
pub invalid_bounds: Vec<InvalidBound>,
pub unused_variables: Vec<String>,
pub discrete_variable_count: usize,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Default)]
pub struct VariableTypeDistribution {
pub free: usize,
pub general: usize,
pub lower_bounded: usize,
pub upper_bounded: usize,
pub double_bounded: usize,
pub binary: usize,
pub integer: usize,
pub semi_continuous: usize,
pub sos: usize,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct FixedVariable {
pub name: String,
pub value: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct InvalidBound {
pub name: String,
pub lower: f64,
pub upper: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct ConstraintAnalysis {
pub type_distribution: ConstraintTypeDistribution,
pub empty_constraints: Vec<String>,
pub singleton_constraints: Vec<SingletonConstraint>,
pub rhs_range: RangeStats,
pub sos_summary: SOSSummary,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Default)]
pub struct ConstraintTypeDistribution {
pub equality: usize,
pub less_than_equal: usize,
pub greater_than_equal: usize,
pub less_than: usize,
pub greater_than: usize,
pub sos1: usize,
pub sos2: usize,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct SingletonConstraint {
pub name: String,
pub variable: String,
pub coefficient: f64,
pub operator: String,
pub rhs: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Default)]
pub struct SOSSummary {
pub s1_count: usize,
pub s2_count: usize,
pub total_sos_variables: usize,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct CoefficientAnalysis {
pub constraint_coeff_range: RangeStats,
pub objective_coeff_range: RangeStats,
pub large_coefficients: Vec<CoefficientLocation>,
pub small_coefficients: Vec<CoefficientLocation>,
pub coefficient_ratio: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Default)]
pub struct RangeStats {
pub min: f64,
pub max: f64,
pub count: usize,
}
impl RangeStats {
const fn new() -> Self {
Self { min: f64::INFINITY, max: f64::NEG_INFINITY, count: 0 }
}
const fn update(&mut self, value: f64) {
self.min = self.min.min(value);
self.max = self.max.max(value);
self.count += 1;
}
fn finalise(self) -> Self {
if self.count == 0 { Self::default() } else { self }
}
#[cfg(test)]
fn from_values(values: &[f64]) -> Self {
if values.is_empty() {
return Self::default();
}
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Self { min, max, count: values.len() }
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct CoefficientLocation {
pub location: String,
pub is_objective: bool,
pub variable: String,
pub value: f64,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
Error,
Warning,
Info,
}
impl Display for IssueSeverity {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
Self::Error => write!(f, "ERROR"),
Self::Warning => write!(f, "WARNING"),
Self::Info => write!(f, "INFO"),
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueCategory {
InvalidBounds,
NumericalScaling,
EmptyConstraint,
UnusedVariable,
FixedVariable,
SingletonConstraint,
Other,
}
impl Display for IssueCategory {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
Self::InvalidBounds => write!(f, "Invalid Bounds"),
Self::NumericalScaling => write!(f, "Numerical Scaling"),
Self::EmptyConstraint => write!(f, "Empty Constraint"),
Self::UnusedVariable => write!(f, "Unused Variable"),
Self::FixedVariable => write!(f, "Fixed Variable"),
Self::SingletonConstraint => write!(f, "Singleton Constraint"),
Self::Other => write!(f, "Other"),
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug, Clone)]
pub struct AnalysisIssue {
pub severity: IssueSeverity,
pub category: IssueCategory,
pub message: String,
pub details: Option<String>,
}
impl Display for AnalysisIssue {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "[{}] {}", self.severity, self.message)?;
if let Some(ref details) = self.details {
write!(f, " ({details})")?;
}
Ok(())
}
}
impl Display for ProblemAnalysis {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
writeln!(f, "=== LP Problem Analysis ===")?;
writeln!(f)?;
writeln!(f, "Summary:")?;
if let Some(ref name) = self.summary.name {
writeln!(f, " Name: {name}")?;
}
writeln!(f, " Sense: {}", self.summary.sense)?;
writeln!(
f,
" Objectives: {} | Constraints: {} | Variables: {}",
self.summary.objective_count, self.summary.constraint_count, self.summary.variable_count
)?;
writeln!(f, " Non-zeros: {} | Density: {:.2}%", self.summary.total_nonzeros, self.summary.density * 100.0)?;
writeln!(f)?;
writeln!(f, "Sparsity:")?;
writeln!(f, " Vars per constraint: min={}, max={}", self.sparsity.min_vars_per_constraint, self.sparsity.max_vars_per_constraint)?;
writeln!(f)?;
writeln!(f, "Variable Types:")?;
let vt = &self.variables.type_distribution;
writeln!(
f,
" Continuous: {} | Binary: {} | Integer: {}",
vt.general + vt.free + vt.lower_bounded + vt.upper_bounded + vt.double_bounded,
vt.binary,
vt.integer
)?;
if vt.semi_continuous > 0 {
writeln!(f, " Semi-continuous: {}", vt.semi_continuous)?;
}
if vt.sos > 0 {
writeln!(f, " SOS: {}", vt.sos)?;
}
writeln!(f)?;
writeln!(f, "Constraint Types:")?;
let ct = &self.constraints.type_distribution;
writeln!(f, " Equality (=): {} | (<=): {} | (>=): {}", ct.equality, ct.less_than_equal, ct.greater_than_equal)?;
if ct.less_than > 0 || ct.greater_than > 0 {
writeln!(f, " Strict: (<): {} | (>): {}", ct.less_than, ct.greater_than)?;
}
if ct.sos1 > 0 || ct.sos2 > 0 {
writeln!(f, " SOS1: {} | SOS2: {}", ct.sos1, ct.sos2)?;
}
writeln!(f)?;
if self.coefficients.constraint_coeff_range.count > 0 {
writeln!(f, "Coefficient Analysis:")?;
let cr = &self.coefficients.constraint_coeff_range;
writeln!(f, " Constraint coeffs: min={:.2e}, max={:.2e}", cr.min, cr.max)?;
if self.coefficients.objective_coeff_range.count > 0 {
let or = &self.coefficients.objective_coeff_range;
writeln!(f, " Objective coeffs: min={:.2e}, max={:.2e}", or.min, or.max)?;
}
if self.coefficients.coefficient_ratio > 1.0 {
writeln!(f, " Coefficient ratio: {:.2e}", self.coefficients.coefficient_ratio)?;
}
writeln!(f)?;
}
if self.issues.is_empty() {
writeln!(f, "No issues detected.")?;
} else {
writeln!(f, "Issues Found: {}", self.issues.len())?;
for issue in &self.issues {
writeln!(f, " {issue}")?;
}
}
Ok(())
}
}
struct CoeffCollector<'a> {
range: &'a mut RangeStats,
large: &'a mut Vec<CoefficientLocation>,
small: &'a mut Vec<CoefficientLocation>,
}
impl<'a> CoeffCollector<'a> {
fn collect(
&mut self,
coefficients: &[crate::model::Coefficient],
location_name: &str,
is_objective: bool,
config: &AnalysisConfig,
interner: &crate::interner::NameInterner,
) {
for coeff in coefficients {
let abs_value = coeff.value.abs();
self.range.update(abs_value);
if abs_value > config.large_coefficient_threshold {
self.large.push(CoefficientLocation {
location: location_name.to_string(),
is_objective,
variable: interner.resolve(coeff.name).to_string(),
value: coeff.value,
});
} else if abs_value > 0.0 && abs_value < config.small_coefficient_threshold {
self.small.push(CoefficientLocation {
location: location_name.to_string(),
is_objective,
variable: interner.resolve(coeff.name).to_string(),
value: coeff.value,
});
}
}
}
}
fn compute_coefficient_ratio(constraint_range: &RangeStats, objective_range: &RangeStats) -> f64 {
let has_values = constraint_range.count > 0 || objective_range.count > 0;
if !has_values {
return 1.0;
}
let mut global_min = f64::INFINITY;
let mut global_max: f64 = 0.0;
let mut has_positive = false;
for range in [constraint_range, objective_range] {
if range.count > 0 && range.max > 0.0 {
has_positive = true;
if range.min > 0.0 && range.min < global_min {
global_min = range.min;
}
if range.max > global_max {
global_max = range.max;
}
}
}
let ratio = if has_positive && global_min > 0.0 && global_min < f64::INFINITY { global_max / global_min } else { 1.0 };
debug_assert!(
!has_positive || ratio >= 1.0 || global_min == f64::INFINITY,
"postcondition: coefficient_ratio must be >= 1.0 when coefficients exist, got: {ratio}"
);
ratio
}
impl LpProblem {
#[must_use]
pub fn analyze(&self) -> ProblemAnalysis {
self.analyze_with_config(&AnalysisConfig::default())
}
#[must_use]
pub fn analyze_with_config(&self, config: &AnalysisConfig) -> ProblemAnalysis {
let summary = self.compute_summary();
let sparsity = self.compute_sparsity_metrics();
let variables = self.analyze_variables();
let constraints = self.analyze_constraints();
let coefficients = self.analyze_coefficients(config);
let issues = self.detect_issues(&summary, &variables, &constraints, &coefficients, config);
ProblemAnalysis { summary, sparsity, variables, constraints, coefficients, issues }
}
fn compute_summary(&self) -> ProblemSummary {
let total_nonzeros = self.count_nonzeros();
let constraint_count = self.constraint_count();
let variable_count = self.variable_count();
#[allow(clippy::cast_precision_loss)]
let density = if constraint_count > 0 && variable_count > 0 {
total_nonzeros as f64 / (constraint_count as f64 * variable_count as f64)
} else {
0.0
};
debug_assert!(density >= 0.0, "postcondition: density must be non-negative, got: {density}");
ProblemSummary {
name: self.name.as_ref().map(std::string::ToString::to_string),
sense: self.sense.to_string(),
objective_count: self.objective_count(),
constraint_count,
variable_count,
total_nonzeros,
density,
}
}
fn count_nonzeros(&self) -> usize {
self.constraints
.values()
.map(|c| match c {
Constraint::Standard { coefficients, .. } => coefficients.len(),
Constraint::SOS { weights, .. } => weights.len(),
})
.sum()
}
fn compute_sparsity_metrics(&self) -> SparsityMetrics {
let (min_v, max_v) = self.constraints.values().fold((usize::MAX, 0usize), |(min_v, max_v), c| {
let n = match c {
Constraint::Standard { coefficients, .. } => coefficients.len(),
Constraint::SOS { weights, .. } => weights.len(),
};
(min_v.min(n), max_v.max(n))
});
SparsityMetrics { min_vars_per_constraint: if min_v == usize::MAX { 0 } else { min_v }, max_vars_per_constraint: max_v }
}
fn analyze_variables(&self) -> VariableAnalysis {
let mut type_distribution = VariableTypeDistribution::default();
let mut free_variables = Vec::new();
let mut fixed_variables = Vec::new();
let mut invalid_bounds = Vec::new();
for (name_id, variable) in &self.variables {
let name_str = self.interner.resolve(*name_id);
match &variable.var_type {
VariableType::Free => {
type_distribution.free += 1;
free_variables.push(name_str.to_string());
}
VariableType::General => type_distribution.general += 1,
VariableType::LowerBound(_) => type_distribution.lower_bounded += 1,
VariableType::UpperBound(_) => type_distribution.upper_bounded += 1,
VariableType::DoubleBound(lower, upper) => {
type_distribution.double_bounded += 1;
if (lower - upper).abs() < f64::EPSILON {
fixed_variables.push(FixedVariable { name: name_str.to_string(), value: *lower });
} else if lower > upper {
invalid_bounds.push(InvalidBound { name: name_str.to_string(), lower: *lower, upper: *upper });
}
}
VariableType::Binary => type_distribution.binary += 1,
VariableType::Integer => type_distribution.integer += 1,
VariableType::SemiContinuous => type_distribution.semi_continuous += 1,
VariableType::SOS => type_distribution.sos += 1,
}
}
let unused_variables = self.find_unused_variables();
let discrete_variable_count = type_distribution.binary + type_distribution.integer;
debug_assert_eq!(
type_distribution.free
+ type_distribution.general
+ type_distribution.lower_bounded
+ type_distribution.upper_bounded
+ type_distribution.double_bounded
+ type_distribution.binary
+ type_distribution.integer
+ type_distribution.semi_continuous
+ type_distribution.sos,
self.variables.len(),
"postcondition: type distribution must sum to total variable count"
);
VariableAnalysis { type_distribution, free_variables, fixed_variables, invalid_bounds, unused_variables, discrete_variable_count }
}
fn find_unused_variables(&self) -> Vec<String> {
let mut used_variables: HashSet<NameId> = HashSet::new();
for objective in self.objectives.values() {
for coeff in &objective.coefficients {
used_variables.insert(coeff.name);
}
}
for constraint in self.constraints.values() {
match constraint {
Constraint::Standard { coefficients, .. } => {
for coeff in coefficients {
used_variables.insert(coeff.name);
}
}
Constraint::SOS { weights, .. } => {
for weight in weights {
used_variables.insert(weight.name);
}
}
}
}
self.variables.keys().filter(|name_id| !used_variables.contains(name_id)).map(|id| self.interner.resolve(*id).to_string()).collect()
}
fn analyze_constraints(&self) -> ConstraintAnalysis {
let mut type_distribution = ConstraintTypeDistribution::default();
let mut empty_constraints = Vec::new();
let mut singleton_constraints = Vec::new();
let mut rhs_range = RangeStats::new();
let mut sos_summary = SOSSummary::default();
for (name_id, constraint) in &self.constraints {
let name_str = self.interner.resolve(*name_id);
match constraint {
Constraint::Standard { coefficients, operator, rhs, .. } => {
match operator {
ComparisonOp::EQ => type_distribution.equality += 1,
ComparisonOp::LTE => type_distribution.less_than_equal += 1,
ComparisonOp::GTE => type_distribution.greater_than_equal += 1,
ComparisonOp::LT => type_distribution.less_than += 1,
ComparisonOp::GT => type_distribution.greater_than += 1,
}
rhs_range.update(*rhs);
if coefficients.is_empty() {
empty_constraints.push(name_str.to_string());
} else if coefficients.len() == 1 {
let coeff = &coefficients[0];
singleton_constraints.push(SingletonConstraint {
name: name_str.to_string(),
variable: self.interner.resolve(coeff.name).to_string(),
coefficient: coeff.value,
operator: operator.to_string(),
rhs: *rhs,
});
}
}
Constraint::SOS { sos_type, weights, .. } => {
match sos_type {
SOSType::S1 => {
type_distribution.sos1 += 1;
sos_summary.s1_count += 1;
}
SOSType::S2 => {
type_distribution.sos2 += 1;
sos_summary.s2_count += 1;
}
}
sos_summary.total_sos_variables += weights.len();
}
}
}
ConstraintAnalysis { type_distribution, empty_constraints, singleton_constraints, rhs_range: rhs_range.finalise(), sos_summary }
}
fn analyze_coefficients(&self, config: &AnalysisConfig) -> CoefficientAnalysis {
let mut constraint_range = RangeStats::new();
let mut objective_range = RangeStats::new();
let mut large_coefficients = Vec::new();
let mut small_coefficients = Vec::new();
let mut constraint_collector =
CoeffCollector { range: &mut constraint_range, large: &mut large_coefficients, small: &mut small_coefficients };
for (name_id, constraint) in &self.constraints {
if let Constraint::Standard { coefficients, .. } = constraint {
let name_str = self.interner.resolve(*name_id);
constraint_collector.collect(coefficients, name_str, false, config, &self.interner);
}
}
let mut objective_collector =
CoeffCollector { range: &mut objective_range, large: constraint_collector.large, small: constraint_collector.small };
for (name_id, objective) in &self.objectives {
let name_str = self.interner.resolve(*name_id);
objective_collector.collect(&objective.coefficients, name_str, true, config, &self.interner);
}
let constraint_coeff_range = constraint_range.finalise();
let objective_coeff_range = objective_range.finalise();
let coefficient_ratio = compute_coefficient_ratio(&constraint_coeff_range, &objective_coeff_range);
CoefficientAnalysis { constraint_coeff_range, objective_coeff_range, large_coefficients, small_coefficients, coefficient_ratio }
}
#[allow(clippy::unused_self)]
fn detect_issues(
&self,
summary: &ProblemSummary,
variables: &VariableAnalysis,
constraints: &ConstraintAnalysis,
coefficients: &CoefficientAnalysis,
config: &AnalysisConfig,
) -> Vec<AnalysisIssue> {
let mut issues = Vec::new();
for invalid in &variables.invalid_bounds {
issues.push(AnalysisIssue {
severity: IssueSeverity::Error,
category: IssueCategory::InvalidBounds,
message: format!("Variable '{}' has invalid bounds: lower ({}) > upper ({})", invalid.name, invalid.lower, invalid.upper),
details: None,
});
}
for name in &constraints.empty_constraints {
issues.push(AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::EmptyConstraint,
message: format!("Constraint '{name}' has no variables"),
details: None,
});
}
if summary.constraint_count >= summary.variable_count && summary.variable_count > 0 {
issues.push(AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::Other,
message: format!(
"Problem may be over-constrained: {} constraints for {} variables",
summary.constraint_count, summary.variable_count
),
details: Some("Over-constrained problems often have degenerate or infeasible solutions".to_string()),
});
}
if constraints.rhs_range.count > 0 && constraints.rhs_range.max > config.large_rhs_threshold {
issues.push(AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::NumericalScaling,
message: format!("Large RHS value ({:.2e}) may cause numerical issues", constraints.rhs_range.max),
details: None,
});
}
if coefficients.coefficient_ratio > config.coefficient_ratio_threshold {
issues.push(AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::NumericalScaling,
message: format!("Large coefficient ratio ({:.2e}) may cause numerical instability", coefficients.coefficient_ratio),
details: Some("Consider rescaling the problem".to_string()),
});
}
for loc in &coefficients.large_coefficients {
issues.push(AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::NumericalScaling,
message: format!(
"Large coefficient ({:.2e}) for variable '{}' in {}",
loc.value,
loc.variable,
if loc.is_objective { "objective" } else { "constraint" }
),
details: Some(loc.location.clone()),
});
}
for fixed in &variables.fixed_variables {
issues.push(AnalysisIssue {
severity: IssueSeverity::Info,
category: IssueCategory::FixedVariable,
message: format!("Variable '{}' is fixed at value {}", fixed.name, fixed.value),
details: None,
});
}
if !constraints.singleton_constraints.is_empty() {
issues.push(AnalysisIssue {
severity: IssueSeverity::Info,
category: IssueCategory::SingletonConstraint,
message: format!(
"{} singleton constraint(s) detected (may represent simple bounds)",
constraints.singleton_constraints.len()
),
details: None,
});
}
for name in &variables.unused_variables {
issues.push(AnalysisIssue {
severity: IssueSeverity::Info,
category: IssueCategory::UnusedVariable,
message: format!("Variable '{name}' is not used in any constraint or objective"),
details: None,
});
}
issues
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_range_stats_empty() {
let stats = RangeStats::from_values(&[]);
assert_eq!(stats.count, 0);
}
#[test]
#[allow(clippy::float_cmp)]
fn test_range_stats_single() {
let stats = RangeStats::from_values(&[5.0]);
assert_eq!(stats.count, 1);
assert_eq!(stats.min, 5.0);
assert_eq!(stats.max, 5.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn test_range_stats_multiple() {
let stats = RangeStats::from_values(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(stats.count, 5);
assert_eq!(stats.min, 1.0);
assert_eq!(stats.max, 5.0);
}
#[test]
fn test_issue_severity_display() {
assert_eq!(IssueSeverity::Error.to_string(), "ERROR");
assert_eq!(IssueSeverity::Warning.to_string(), "WARNING");
assert_eq!(IssueSeverity::Info.to_string(), "INFO");
}
#[test]
fn test_issue_category_display() {
assert_eq!(IssueCategory::InvalidBounds.to_string(), "Invalid Bounds");
assert_eq!(IssueCategory::NumericalScaling.to_string(), "Numerical Scaling");
}
#[test]
fn test_analysis_issue_display() {
let issue = AnalysisIssue {
severity: IssueSeverity::Warning,
category: IssueCategory::NumericalScaling,
message: "Test message".to_string(),
details: Some("Details here".to_string()),
};
let display = issue.to_string();
assert!(display.contains("WARNING"));
assert!(display.contains("Test message"));
assert!(display.contains("Details here"));
}
#[test]
#[allow(clippy::float_cmp)]
fn test_default_config() {
let config = AnalysisConfig::default();
assert_eq!(config.large_coefficient_threshold, 1e9);
assert_eq!(config.small_coefficient_threshold, 1e-9);
}
}