480 lines
15 KiB
Rust
480 lines
15 KiB
Rust
//! Tests for verbose mode diagnostics (Story 7.4).
|
|
//!
|
|
//! Covers:
|
|
//! - VerboseConfig default behavior
|
|
//! - IterationDiagnostics collection
|
|
//! - Jacobian condition number estimation
|
|
//! - ConvergenceDiagnostics summary
|
|
|
|
use entropyk_solver::jacobian::JacobianMatrix;
|
|
use entropyk_solver::{
|
|
ConvergenceDiagnostics, IterationDiagnostics, SolverSwitchEvent, SolverType, SwitchReason,
|
|
VerboseConfig, VerboseOutputFormat,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Task 1: VerboseConfig Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_verbose_config_default_is_disabled() {
|
|
let config = VerboseConfig::default();
|
|
|
|
// All features should be disabled by default for backward compatibility
|
|
assert!(!config.enabled, "enabled should be false by default");
|
|
assert!(!config.log_residuals, "log_residuals should be false by default");
|
|
assert!(
|
|
!config.log_jacobian_condition,
|
|
"log_jacobian_condition should be false by default"
|
|
);
|
|
assert!(
|
|
!config.log_solver_switches,
|
|
"log_solver_switches should be false by default"
|
|
);
|
|
assert!(
|
|
!config.dump_final_state,
|
|
"dump_final_state should be false by default"
|
|
);
|
|
assert_eq!(
|
|
config.output_format,
|
|
VerboseOutputFormat::Both,
|
|
"output_format should default to Both"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verbose_config_all_enabled() {
|
|
let config = VerboseConfig::all_enabled();
|
|
|
|
assert!(config.enabled, "enabled should be true");
|
|
assert!(config.log_residuals, "log_residuals should be true");
|
|
assert!(config.log_jacobian_condition, "log_jacobian_condition should be true");
|
|
assert!(config.log_solver_switches, "log_solver_switches should be true");
|
|
assert!(config.dump_final_state, "dump_final_state should be true");
|
|
}
|
|
|
|
#[test]
|
|
fn test_verbose_config_is_any_enabled() {
|
|
// All disabled
|
|
let config = VerboseConfig::default();
|
|
assert!(!config.is_any_enabled(), "no features should be enabled");
|
|
|
|
// Master switch off but features on
|
|
let config = VerboseConfig {
|
|
enabled: false,
|
|
log_residuals: true,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
!config.is_any_enabled(),
|
|
"should be false when master switch is off"
|
|
);
|
|
|
|
// Master switch on but all features off
|
|
let config = VerboseConfig {
|
|
enabled: true,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
!config.is_any_enabled(),
|
|
"should be false when no features are enabled"
|
|
);
|
|
|
|
// Master switch on and one feature on
|
|
let config = VerboseConfig {
|
|
enabled: true,
|
|
log_residuals: true,
|
|
..Default::default()
|
|
};
|
|
assert!(config.is_any_enabled(), "should be true when one feature is enabled");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Task 2: IterationDiagnostics Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_iteration_diagnostics_creation() {
|
|
let diag = IterationDiagnostics {
|
|
iteration: 5,
|
|
residual_norm: 1e-4,
|
|
delta_norm: 1e-5,
|
|
alpha: Some(0.5),
|
|
jacobian_frozen: true,
|
|
jacobian_condition: Some(1e3),
|
|
};
|
|
|
|
assert_eq!(diag.iteration, 5);
|
|
assert!((diag.residual_norm - 1e-4).abs() < 1e-15);
|
|
assert!((diag.delta_norm - 1e-5).abs() < 1e-15);
|
|
assert_eq!(diag.alpha, Some(0.5));
|
|
assert!(diag.jacobian_frozen);
|
|
assert_eq!(diag.jacobian_condition, Some(1e3));
|
|
}
|
|
|
|
#[test]
|
|
fn test_iteration_diagnostics_without_alpha() {
|
|
// Sequential Substitution doesn't use line search
|
|
let diag = IterationDiagnostics {
|
|
iteration: 3,
|
|
residual_norm: 1e-3,
|
|
delta_norm: 1e-4,
|
|
alpha: None,
|
|
jacobian_frozen: false,
|
|
jacobian_condition: None,
|
|
};
|
|
|
|
assert_eq!(diag.alpha, None);
|
|
assert!(!diag.jacobian_frozen);
|
|
assert_eq!(diag.jacobian_condition, None);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Task 3: Jacobian Condition Number Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_jacobian_condition_number_well_conditioned() {
|
|
// Identity-like matrix (well-conditioned)
|
|
let entries = vec![(0, 0, 2.0), (1, 1, 1.0)];
|
|
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
|
|
|
let cond = j.estimate_condition_number().expect("should compute condition number");
|
|
|
|
// Condition number of diagonal matrix is max/min diagonal entry
|
|
assert!(
|
|
cond < 10.0,
|
|
"Expected low condition number for well-conditioned matrix, got {}",
|
|
cond
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_jacobian_condition_number_ill_conditioned() {
|
|
// Nearly singular matrix
|
|
let entries = vec![
|
|
(0, 0, 1.0),
|
|
(0, 1, 1.0),
|
|
(1, 0, 1.0),
|
|
(1, 1, 1.0000001),
|
|
];
|
|
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
|
|
|
let cond = j.estimate_condition_number().expect("should compute condition number");
|
|
|
|
assert!(
|
|
cond > 1e6,
|
|
"Expected high condition number for ill-conditioned matrix, got {}",
|
|
cond
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_jacobian_condition_number_identity() {
|
|
// Identity matrix has condition number 1
|
|
let entries = vec![(0, 0, 1.0), (1, 1, 1.0), (2, 2, 1.0)];
|
|
let j = JacobianMatrix::from_builder(&entries, 3, 3);
|
|
|
|
let cond = j.estimate_condition_number().expect("should compute condition number");
|
|
|
|
assert!(
|
|
(cond - 1.0).abs() < 1e-10,
|
|
"Expected condition number 1 for identity matrix, got {}",
|
|
cond
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_jacobian_condition_number_empty_matrix() {
|
|
// Empty matrix (0x0)
|
|
let j = JacobianMatrix::zeros(0, 0);
|
|
|
|
let cond = j.estimate_condition_number();
|
|
|
|
assert!(
|
|
cond.is_none(),
|
|
"Expected None for empty matrix"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Task 4: SolverSwitchEvent Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_solver_switch_event_creation() {
|
|
let event = SolverSwitchEvent {
|
|
from_solver: SolverType::NewtonRaphson,
|
|
to_solver: SolverType::SequentialSubstitution,
|
|
reason: SwitchReason::Divergence,
|
|
iteration: 10,
|
|
residual_at_switch: 1e6,
|
|
};
|
|
|
|
assert_eq!(event.from_solver, SolverType::NewtonRaphson);
|
|
assert_eq!(event.to_solver, SolverType::SequentialSubstitution);
|
|
assert_eq!(event.reason, SwitchReason::Divergence);
|
|
assert_eq!(event.iteration, 10);
|
|
assert!((event.residual_at_switch - 1e6).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_solver_type_display() {
|
|
assert_eq!(
|
|
format!("{}", SolverType::NewtonRaphson),
|
|
"Newton-Raphson"
|
|
);
|
|
assert_eq!(
|
|
format!("{}", SolverType::SequentialSubstitution),
|
|
"Sequential Substitution"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_switch_reason_display() {
|
|
assert_eq!(format!("{}", SwitchReason::Divergence), "divergence detected");
|
|
assert_eq!(
|
|
format!("{}", SwitchReason::SlowConvergence),
|
|
"slow convergence"
|
|
);
|
|
assert_eq!(format!("{}", SwitchReason::UserRequested), "user requested");
|
|
assert_eq!(
|
|
format!("{}", SwitchReason::ReturnToNewton),
|
|
"returning to Newton after stabilization"
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Task 5: ConvergenceDiagnostics Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_default() {
|
|
let diag = ConvergenceDiagnostics::default();
|
|
|
|
assert_eq!(diag.iterations, 0);
|
|
assert!((diag.final_residual - 0.0).abs() < 1e-15);
|
|
assert!(!diag.converged);
|
|
assert!(diag.iteration_history.is_empty());
|
|
assert!(diag.solver_switches.is_empty());
|
|
assert!(diag.final_state.is_none());
|
|
assert!(diag.jacobian_condition_final.is_none());
|
|
assert_eq!(diag.timing_ms, 0);
|
|
assert!(diag.final_solver.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_with_capacity() {
|
|
let diag = ConvergenceDiagnostics::with_capacity(100);
|
|
|
|
// Capacity should be pre-allocated
|
|
assert!(diag.iteration_history.capacity() >= 100);
|
|
assert!(diag.iteration_history.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_push_iteration() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
|
|
diag.push_iteration(IterationDiagnostics {
|
|
iteration: 0,
|
|
residual_norm: 1.0,
|
|
delta_norm: 0.0,
|
|
alpha: None,
|
|
jacobian_frozen: false,
|
|
jacobian_condition: None,
|
|
});
|
|
|
|
diag.push_iteration(IterationDiagnostics {
|
|
iteration: 1,
|
|
residual_norm: 0.5,
|
|
delta_norm: 0.5,
|
|
alpha: Some(1.0),
|
|
jacobian_frozen: false,
|
|
jacobian_condition: Some(100.0),
|
|
});
|
|
|
|
assert_eq!(diag.iteration_history.len(), 2);
|
|
assert_eq!(diag.iteration_history[0].iteration, 0);
|
|
assert_eq!(diag.iteration_history[1].iteration, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_push_switch() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
|
|
diag.push_switch(SolverSwitchEvent {
|
|
from_solver: SolverType::NewtonRaphson,
|
|
to_solver: SolverType::SequentialSubstitution,
|
|
reason: SwitchReason::Divergence,
|
|
iteration: 5,
|
|
residual_at_switch: 1e10,
|
|
});
|
|
|
|
assert_eq!(diag.solver_switches.len(), 1);
|
|
assert_eq!(diag.solver_switches[0].iteration, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_summary_converged() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 25;
|
|
diag.final_residual = 1e-8;
|
|
diag.best_residual = 1e-8;
|
|
diag.converged = true;
|
|
diag.timing_ms = 150;
|
|
diag.final_solver = Some(SolverType::NewtonRaphson);
|
|
diag.jacobian_condition_final = Some(1e4);
|
|
|
|
let summary = diag.summary();
|
|
|
|
assert!(summary.contains("Converged: YES"));
|
|
assert!(summary.contains("Iterations: 25"));
|
|
// The format uses {:.3e} which produces like "1.000e-08"
|
|
assert!(summary.contains("Final Residual:"));
|
|
assert!(summary.contains("Solver Switches: 0"));
|
|
assert!(summary.contains("Timing: 150 ms"));
|
|
assert!(summary.contains("Jacobian Condition:"));
|
|
assert!(summary.contains("Final Solver: Newton-Raphson"));
|
|
// Should NOT contain ill-conditioned warning
|
|
assert!(!summary.contains("WARNING"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_summary_ill_conditioned() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 100;
|
|
diag.final_residual = 1e-2;
|
|
diag.best_residual = 1e-3;
|
|
diag.converged = false;
|
|
diag.timing_ms = 500;
|
|
diag.jacobian_condition_final = Some(1e12);
|
|
|
|
let summary = diag.summary();
|
|
|
|
assert!(summary.contains("Converged: NO"));
|
|
assert!(summary.contains("WARNING: ill-conditioned"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_summary_with_switches() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 50;
|
|
diag.final_residual = 1e-6;
|
|
diag.best_residual = 1e-6;
|
|
diag.converged = true;
|
|
diag.timing_ms = 200;
|
|
|
|
diag.push_switch(SolverSwitchEvent {
|
|
from_solver: SolverType::NewtonRaphson,
|
|
to_solver: SolverType::SequentialSubstitution,
|
|
reason: SwitchReason::Divergence,
|
|
iteration: 10,
|
|
residual_at_switch: 1e10,
|
|
});
|
|
|
|
let summary = diag.summary();
|
|
|
|
assert!(summary.contains("Solver Switches: 1"));
|
|
}
|
|
|
|
// =============================================================================
|
|
// VerboseOutputFormat Tests
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_verbose_output_format_default() {
|
|
let format = VerboseOutputFormat::default();
|
|
assert_eq!(format, VerboseOutputFormat::Both);
|
|
}
|
|
|
|
// =============================================================================
|
|
// JSON Serialization Tests (Story 7.4 - AC4)
|
|
// =============================================================================
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_json_serialization() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 50;
|
|
diag.final_residual = 1e-6;
|
|
diag.best_residual = 1e-7;
|
|
diag.converged = true;
|
|
diag.timing_ms = 250;
|
|
diag.final_solver = Some(SolverType::NewtonRaphson);
|
|
diag.jacobian_condition_final = Some(1e5);
|
|
|
|
diag.push_iteration(IterationDiagnostics {
|
|
iteration: 1,
|
|
residual_norm: 1.0,
|
|
delta_norm: 0.5,
|
|
alpha: Some(1.0),
|
|
jacobian_frozen: false,
|
|
jacobian_condition: Some(100.0),
|
|
});
|
|
|
|
diag.push_switch(SolverSwitchEvent {
|
|
from_solver: SolverType::NewtonRaphson,
|
|
to_solver: SolverType::SequentialSubstitution,
|
|
reason: SwitchReason::Divergence,
|
|
iteration: 10,
|
|
residual_at_switch: 1e6,
|
|
});
|
|
|
|
// Test JSON serialization
|
|
let json = serde_json::to_string(&diag).expect("Should serialize to JSON");
|
|
assert!(json.contains("\"iterations\":50"));
|
|
assert!(json.contains("\"converged\":true"));
|
|
assert!(json.contains("\"NewtonRaphson\""));
|
|
assert!(json.contains("\"Divergence\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_diagnostics_round_trip() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 25;
|
|
diag.final_residual = 1e-8;
|
|
diag.converged = true;
|
|
diag.timing_ms = 100;
|
|
diag.final_solver = Some(SolverType::SequentialSubstitution);
|
|
|
|
// Serialize to JSON
|
|
let json = serde_json::to_string(&diag).expect("Should serialize");
|
|
|
|
// Deserialize back
|
|
let restored: ConvergenceDiagnostics =
|
|
serde_json::from_str(&json).expect("Should deserialize");
|
|
|
|
assert_eq!(restored.iterations, 25);
|
|
assert!((restored.final_residual - 1e-8).abs() < 1e-20);
|
|
assert!(restored.converged);
|
|
assert_eq!(restored.timing_ms, 100);
|
|
assert_eq!(restored.final_solver, Some(SolverType::SequentialSubstitution));
|
|
}
|
|
|
|
#[test]
|
|
fn test_dump_diagnostics_json_format() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 10;
|
|
diag.final_residual = 1e-4;
|
|
diag.converged = false;
|
|
|
|
let json_output = diag.dump_diagnostics(VerboseOutputFormat::Json);
|
|
assert!(json_output.starts_with('{'));
|
|
// to_string_pretty adds spaces after colons
|
|
assert!(json_output.contains("\"iterations\"") && json_output.contains("10"));
|
|
assert!(json_output.contains("\"converged\"") && json_output.contains("false"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_dump_diagnostics_log_format() {
|
|
let mut diag = ConvergenceDiagnostics::new();
|
|
diag.iterations = 10;
|
|
diag.final_residual = 1e-4;
|
|
diag.converged = false;
|
|
|
|
let log_output = diag.dump_diagnostics(VerboseOutputFormat::Log);
|
|
assert!(log_output.contains("Convergence Diagnostics Summary"));
|
|
assert!(log_output.contains("Converged: NO"));
|
|
assert!(log_output.contains("Iterations: 10"));
|
|
}
|