use std::fmt::{Display, Formatter, Result as FmtResult};
use rustc_hash::FxHashSet;
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 {
let result = if self.count == 0 { Self::default() } else { self };
debug_assert!(
result.count == 0 || result.min <= result.max,
"postcondition: finalised range min ({}) must not exceed max ({})",
result.min,
result.max
);
result
}
#[cfg(test)]
fn from_values(values: &[f64]) -> Self {
let mut stats = Self::new();
for &v in values {
stats.update(v);
}
stats.finalise()
}
}
#[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(())
}
}
#[allow(clippy::too_many_arguments)]
fn collect_coefficient_stats(
coefficients: &[crate::model::Coefficient],
location_name: &str,
is_objective: bool,
config: &AnalysisConfig,
interner: &crate::interner::NameInterner,
range: &mut RangeStats,
large: &mut Vec<CoefficientLocation>,
small: &mut Vec<CoefficientLocation>,
) {
debug_assert!(!location_name.is_empty(), "location name must not be empty");
debug_assert!(
config.small_coefficient_threshold <= config.large_coefficient_threshold,
"small threshold must not exceed large threshold"
);
for coeff in coefficients {
let abs_value = coeff.value.abs();
range.update(abs_value);
if abs_value > config.large_coefficient_threshold {
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 {
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))
});
let metrics =
SparsityMetrics { min_vars_per_constraint: if min_v == usize::MAX { 0 } else { min_v }, max_vars_per_constraint: max_v };
debug_assert!(
metrics.min_vars_per_constraint <= metrics.max_vars_per_constraint,
"postcondition: min vars per constraint ({}) must not exceed max ({})",
metrics.min_vars_per_constraint,
metrics.max_vars_per_constraint
);
metrics
}
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: FxHashSet<NameId> = FxHashSet::default();
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);
}
}
}
}
let unused: Vec<String> = self
.variables
.keys()
.filter(|name_id| !used_variables.contains(name_id))
.map(|id| self.interner.resolve(*id).to_string())
.collect();
debug_assert!(
unused.len() <= self.variables.len(),
"postcondition: unused variable count ({}) cannot exceed total variables ({})",
unused.len(),
self.variables.len()
);
unused
}
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();
}
}
}
debug_assert_eq!(
type_distribution.equality
+ type_distribution.less_than_equal
+ type_distribution.greater_than_equal
+ type_distribution.less_than
+ type_distribution.greater_than
+ type_distribution.sos1
+ type_distribution.sos2,
self.constraints.len(),
"postcondition: constraint type distribution must sum to total constraint count"
);
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();
for (name_id, constraint) in &self.constraints {
if let Constraint::Standard { coefficients, .. } = constraint {
let name_str = self.interner.resolve(*name_id);
collect_coefficient_stats(
coefficients,
name_str,
false,
config,
&self.interner,
&mut constraint_range,
&mut large_coefficients,
&mut small_coefficients,
);
}
}
for (name_id, objective) in &self.objectives {
let name_str = self.interner.resolve(*name_id);
collect_coefficient_stats(
&objective.coefficients,
name_str,
true,
config,
&self.interner,
&mut objective_range,
&mut large_coefficients,
&mut small_coefficients,
);
}
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);
debug_assert!(coefficient_ratio >= 1.0, "postcondition: coefficient ratio must be >= 1.0, got: {coefficient_ratio}");
CoefficientAnalysis { constraint_coeff_range, objective_coeff_range, large_coefficients, small_coefficients, coefficient_ratio }
}
fn detect_issues(
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::*;
use crate::model::{Coefficient, Constraint, Objective, Variable};
#[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);
}
fn add_standard_constraint(problem: &mut LpProblem, name: &str, variables: &[&str], operator: ComparisonOp, rhs: f64) {
let name_id = problem.intern(name);
let coefficients = variables.iter().map(|v| Coefficient { name: problem.intern(v), value: 1.0 }).collect();
problem.add_constraint(Constraint::Standard { name: name_id, coefficients, operator, rhs, byte_offset: None });
}
#[test]
#[allow(clippy::float_cmp)]
fn test_analyze_truly_empty_problem() {
let analysis = LpProblem::new().analyze();
assert_eq!(analysis.summary.objective_count, 0);
assert_eq!(analysis.summary.constraint_count, 0);
assert_eq!(analysis.summary.variable_count, 0);
assert_eq!(analysis.summary.total_nonzeros, 0);
assert_eq!(analysis.summary.density, 0.0);
assert_eq!(analysis.coefficients.coefficient_ratio, 1.0);
assert!(analysis.issues.is_empty(), "an empty problem must raise no issues: {:?}", analysis.issues);
}
#[test]
#[allow(clippy::float_cmp)]
fn test_fixed_variable_reports_info_issue() {
let mut problem = LpProblem::new();
let x_id = problem.intern("x");
problem.add_variable(Variable::new(x_id).with_var_type(VariableType::DoubleBound(5.0, 5.0)));
let analysis = problem.analyze();
assert_eq!(analysis.variables.fixed_variables.len(), 1);
assert_eq!(analysis.variables.fixed_variables[0].name, "x");
assert_eq!(analysis.variables.fixed_variables[0].value, 5.0);
assert!(
analysis.issues.iter().any(|i| i.severity == IssueSeverity::Info && i.category == IssueCategory::FixedVariable),
"expected a FixedVariable Info issue: {:?}",
analysis.issues
);
}
#[test]
#[allow(clippy::float_cmp)]
fn test_invalid_bounds_report_error_issue() {
let mut problem = LpProblem::new();
let x_id = problem.intern("x");
problem.add_variable(Variable::new(x_id).with_var_type(VariableType::DoubleBound(10.0, 1.0)));
let analysis = problem.analyze();
assert_eq!(analysis.variables.invalid_bounds.len(), 1);
assert_eq!(analysis.variables.invalid_bounds[0].name, "x");
assert_eq!(analysis.variables.invalid_bounds[0].lower, 10.0);
assert_eq!(analysis.variables.invalid_bounds[0].upper, 1.0);
assert!(
analysis.issues.iter().any(|i| i.severity == IssueSeverity::Error && i.category == IssueCategory::InvalidBounds),
"expected an InvalidBounds Error issue: {:?}",
analysis.issues
);
}
#[test]
fn test_empty_constraint_reports_warning_issue() {
let mut problem = LpProblem::new();
add_standard_constraint(&mut problem, "empty_c", &[], ComparisonOp::LTE, 5.0);
add_standard_constraint(&mut problem, "c1", &["x", "y", "z"], ComparisonOp::LTE, 10.0);
let analysis = problem.analyze();
assert_eq!(analysis.constraints.empty_constraints, vec!["empty_c".to_string()]);
assert!(
analysis.issues.iter().any(|i| i.severity == IssueSeverity::Warning && i.category == IssueCategory::EmptyConstraint),
"expected an EmptyConstraint Warning issue: {:?}",
analysis.issues
);
}
#[test]
fn test_over_constrained_warning_boundary() {
let mut equal = LpProblem::new();
add_standard_constraint(&mut equal, "c1", &["x", "y"], ComparisonOp::LTE, 10.0);
add_standard_constraint(&mut equal, "c2", &["x"], ComparisonOp::GTE, 1.0);
let analysis = equal.analyze();
assert_eq!(analysis.summary.constraint_count, analysis.summary.variable_count);
assert!(
analysis.issues.iter().any(|i| i.category == IssueCategory::Other && i.message.contains("over-constrained")),
"expected an over-constrained warning at the boundary: {:?}",
analysis.issues
);
let mut under = LpProblem::new();
add_standard_constraint(&mut under, "c1", &["x", "y"], ComparisonOp::LTE, 10.0);
let analysis = under.analyze();
assert!(
!analysis.issues.iter().any(|i| i.message.contains("over-constrained")),
"no over-constrained warning expected below the boundary: {:?}",
analysis.issues
);
}
#[test]
fn test_unused_variable_reports_info_issue() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x_id = problem.intern("x");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
add_standard_constraint(&mut problem, "c1", &["x"], ComparisonOp::GTE, 1.0);
let unused_id = problem.intern("unused");
problem.add_variable(Variable::new(unused_id).with_var_type(VariableType::LowerBound(0.0)));
let analysis = problem.analyze();
assert_eq!(analysis.variables.unused_variables, vec!["unused".to_string()]);
assert!(
analysis
.issues
.iter()
.any(|i| i.severity == IssueSeverity::Info && i.category == IssueCategory::UnusedVariable && i.message.contains("unused")),
"expected an UnusedVariable Info issue: {:?}",
analysis.issues
);
}
}