chore: remove deprecated flow_boundary and update docs to match new architecture

This commit is contained in:
Sepehr
2026-03-01 20:00:09 +01:00
parent 20700afce8
commit d88914a44f
105 changed files with 11222 additions and 2994 deletions

View File

@@ -177,6 +177,62 @@ impl JacobianMatrix {
}
}
/// Estimates the condition number of the Jacobian matrix.
///
/// The condition number κ = σ_max / σ_min indicates how ill-conditioned
/// the matrix is. Values > 1e10 indicate an ill-conditioned system that
/// may cause numerical instability in the solver.
///
/// Uses SVD decomposition to compute singular values. This is an O(n³)
/// operation and should only be used for diagnostics.
///
/// # Returns
///
/// * `Some(κ)` - The condition number (ratio of largest to smallest singular value)
/// * `None` - If the matrix is rank-deficient (σ_min = 0)
///
/// # Example
///
/// ```rust
/// use entropyk_solver::jacobian::JacobianMatrix;
///
/// // Well-conditioned matrix
/// 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().unwrap();
/// assert!(cond < 10.0, "Expected low condition number, got {}", cond);
///
/// // Ill-conditioned matrix (nearly singular)
/// let bad_entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0000001)];
/// let bad_j = JacobianMatrix::from_builder(&bad_entries, 2, 2);
/// let bad_cond = bad_j.estimate_condition_number().unwrap();
/// assert!(bad_cond > 1e7, "Expected high condition number, got {}", bad_cond);
/// ```
pub fn estimate_condition_number(&self) -> Option<f64> {
// Handle empty matrices
if self.0.nrows() == 0 || self.0.ncols() == 0 {
return None;
}
// Use SVD to get singular values
let svd = self.0.clone().svd(true, true);
// Get singular values
let singular_values = svd.singular_values;
if singular_values.len() == 0 {
return None;
}
let sigma_max = singular_values.max();
let sigma_min = singular_values.iter().filter(|&&s| s > 0.0).min_by(|a, b| a.partial_cmp(b).unwrap()).copied();
match sigma_min {
Some(min) => Some(sigma_max / min),
None => None, // Matrix is rank-deficient
}
}
/// Computes a numerical Jacobian via finite differences.
///
/// For each state variable x_j, perturbs by epsilon and computes:

View File

@@ -34,7 +34,9 @@ pub use jacobian::JacobianMatrix;
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
pub use metadata::SimulationMetadata;
pub use solver::{
ConvergedState, ConvergenceStatus, JacobianFreezingConfig, Solver, SolverError, TimeoutConfig,
ConvergedState, ConvergenceStatus, ConvergenceDiagnostics, IterationDiagnostics,
JacobianFreezingConfig, Solver, SolverError, SolverSwitchEvent, SolverType, SwitchReason,
TimeoutConfig, VerboseConfig, VerboseOutputFormat,
};
pub use strategies::{
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, SolverStrategy,

View File

@@ -3,6 +3,7 @@
//! Provides the `Solver` trait (object-safe interface) and `SolverStrategy` enum
//! (zero-cost static dispatch) for solver strategies.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
@@ -126,6 +127,12 @@ pub struct ConvergedState {
/// Traceability metadata for reproducibility.
pub metadata: SimulationMetadata,
/// Optional convergence diagnostics (Story 7.4).
///
/// `Some(diagnostics)` when verbose mode was enabled during solving.
/// `None` when verbose mode was disabled (backward-compatible default).
pub diagnostics: Option<ConvergenceDiagnostics>,
}
impl ConvergedState {
@@ -144,6 +151,7 @@ impl ConvergedState {
status,
convergence_report: None,
metadata,
diagnostics: None,
}
}
@@ -163,6 +171,27 @@ impl ConvergedState {
status,
convergence_report: Some(report),
metadata,
diagnostics: None,
}
}
/// Creates a `ConvergedState` with attached diagnostics.
pub fn with_diagnostics(
state: Vec<f64>,
iterations: usize,
final_residual: f64,
status: ConvergenceStatus,
metadata: SimulationMetadata,
diagnostics: ConvergenceDiagnostics,
) -> Self {
Self {
state,
iterations,
final_residual,
status,
convergence_report: None,
metadata,
diagnostics: Some(diagnostics),
}
}
@@ -351,6 +380,336 @@ impl Default for JacobianFreezingConfig {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Verbose Mode Configuration (Story 7.4)
// ─────────────────────────────────────────────────────────────────────────────
/// Output format for verbose diagnostics.
///
/// Controls how convergence diagnostics are presented to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VerboseOutputFormat {
/// Output diagnostics via `tracing` logs only.
Log,
/// Output diagnostics as structured JSON.
Json,
/// Output via both logging and JSON.
#[default]
Both,
}
/// Configuration for debug verbose mode in solvers.
///
/// When enabled, provides detailed convergence diagnostics to help debug
/// non-converging thermodynamic systems. This includes per-iteration residuals,
/// Jacobian condition numbers, solver switch events, and final state dumps.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{VerboseConfig, VerboseOutputFormat};
///
/// // Enable all verbose features
/// let verbose = VerboseConfig {
/// enabled: true,
/// log_residuals: true,
/// log_jacobian_condition: true,
/// log_solver_switches: true,
/// dump_final_state: true,
/// output_format: VerboseOutputFormat::Both,
/// };
///
/// // Default: all features disabled (backward compatible)
/// let default_config = VerboseConfig::default();
/// assert!(!default_config.enabled);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VerboseConfig {
/// Master switch for verbose mode.
///
/// When `false`, all verbose output is disabled regardless of other settings.
/// Default: `false` (backward compatible).
pub enabled: bool,
/// Log residuals at each iteration.
///
/// When `true`, emits `tracing::info!` logs with iteration number,
/// residual norm, and delta from previous iteration.
/// Default: `false`.
pub log_residuals: bool,
/// Report Jacobian condition number.
///
/// When `true`, computes and logs the Jacobian condition number
/// (ratio of largest to smallest singular values). Values > 1e10
/// indicate an ill-conditioned system.
/// Default: `false`.
///
/// **Note:** Condition number estimation is O(n³) and may impact
/// performance for large systems.
pub log_jacobian_condition: bool,
/// Log solver switch events.
///
/// When `true`, logs when the fallback solver switches between
/// Newton-Raphson and Sequential Substitution, including the reason.
/// Default: `false`.
pub log_solver_switches: bool,
/// Dump final state on non-convergence.
///
/// When `true`, dumps the final state vector and diagnostics
/// when the solver fails to converge, for post-mortem analysis.
/// Default: `false`.
pub dump_final_state: bool,
/// Output format for diagnostics.
///
/// Default: `VerboseOutputFormat::Both`.
pub output_format: VerboseOutputFormat,
}
impl Default for VerboseConfig {
fn default() -> Self {
Self {
enabled: false,
log_residuals: false,
log_jacobian_condition: false,
log_solver_switches: false,
dump_final_state: false,
output_format: VerboseOutputFormat::default(),
}
}
}
impl VerboseConfig {
/// Creates a new `VerboseConfig` with all features enabled.
pub fn all_enabled() -> Self {
Self {
enabled: true,
log_residuals: true,
log_jacobian_condition: true,
log_solver_switches: true,
dump_final_state: true,
output_format: VerboseOutputFormat::Both,
}
}
/// Returns `true` if any verbose feature is enabled.
pub fn is_any_enabled(&self) -> bool {
self.enabled
&& (self.log_residuals
|| self.log_jacobian_condition
|| self.log_solver_switches
|| self.dump_final_state)
}
}
/// Per-iteration diagnostics captured during solving.
///
/// Records the state of the solver at each iteration for debugging
/// and post-mortem analysis.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IterationDiagnostics {
/// Iteration number (0-indexed).
pub iteration: usize,
/// $\ell_2$ norm of the residual vector.
pub residual_norm: f64,
/// Norm of the change from previous iteration ($\|\Delta x\|$).
pub delta_norm: f64,
/// Line search step size (Newton-Raphson only).
///
/// `None` for Sequential Substitution or if line search was not used.
pub alpha: Option<f64>,
/// Whether the Jacobian was reused (frozen) this iteration.
pub jacobian_frozen: bool,
/// Jacobian condition number (if computed).
///
/// Only populated when `log_jacobian_condition` is enabled.
pub jacobian_condition: Option<f64>,
}
/// Type of solver being used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SolverType {
/// Newton-Raphson solver.
NewtonRaphson,
/// Sequential Substitution (Picard) solver.
SequentialSubstitution,
}
impl std::fmt::Display for SolverType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SolverType::NewtonRaphson => write!(f, "Newton-Raphson"),
SolverType::SequentialSubstitution => write!(f, "Sequential Substitution"),
}
}
}
/// Reason for solver switch in fallback strategy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwitchReason {
/// Newton-Raphson diverged (residual increasing).
Divergence,
/// Newton-Raphson converging too slowly.
SlowConvergence,
/// User explicitly requested switch.
UserRequested,
/// Returning to Newton-Raphson after Picard stabilized.
ReturnToNewton,
}
impl std::fmt::Display for SwitchReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SwitchReason::Divergence => write!(f, "divergence detected"),
SwitchReason::SlowConvergence => write!(f, "slow convergence"),
SwitchReason::UserRequested => write!(f, "user requested"),
SwitchReason::ReturnToNewton => write!(f, "returning to Newton after stabilization"),
}
}
}
/// Event record for solver switches in fallback strategy.
///
/// Captures when and why the solver switched between strategies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverSwitchEvent {
/// Solver being switched from.
pub from_solver: SolverType,
/// Solver being switched to.
pub to_solver: SolverType,
/// Reason for the switch.
pub reason: SwitchReason,
/// Iteration number at which the switch occurred.
pub iteration: usize,
/// Residual norm at the time of switch.
pub residual_at_switch: f64,
}
/// Comprehensive convergence diagnostics for a solve attempt.
///
/// Contains all diagnostic information collected during solving,
/// suitable for JSON serialization and post-mortem analysis.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConvergenceDiagnostics {
/// Total iterations performed.
pub iterations: usize,
/// Final residual norm.
pub final_residual: f64,
/// Best residual norm achieved during iteration.
pub best_residual: f64,
/// Whether the solver converged.
pub converged: bool,
/// Per-iteration diagnostics history.
pub iteration_history: Vec<IterationDiagnostics>,
/// Solver switch events (fallback strategy only).
pub solver_switches: Vec<SolverSwitchEvent>,
/// Final state vector (populated on non-convergence if `dump_final_state` enabled).
pub final_state: Option<Vec<f64>>,
/// Jacobian condition number at final iteration.
pub jacobian_condition_final: Option<f64>,
/// Total solve time in milliseconds.
pub timing_ms: u64,
/// Solver type used for the final iteration.
pub final_solver: Option<SolverType>,
}
impl ConvergenceDiagnostics {
/// Creates a new empty `ConvergenceDiagnostics`.
pub fn new() -> Self {
Self::default()
}
/// Pre-allocates iteration history for `max_iterations` entries.
pub fn with_capacity(max_iterations: usize) -> Self {
Self {
iteration_history: Vec::with_capacity(max_iterations),
..Self::default()
}
}
/// Adds an iteration's diagnostics to the history.
pub fn push_iteration(&mut self, diagnostics: IterationDiagnostics) {
self.iteration_history.push(diagnostics);
}
/// Records a solver switch event.
pub fn push_switch(&mut self, event: SolverSwitchEvent) {
self.solver_switches.push(event);
}
/// Returns a human-readable summary of the diagnostics.
pub fn summary(&self) -> String {
let converged_str = if self.converged { "YES" } else { "NO" };
let switch_count = self.solver_switches.len();
let mut summary = format!(
"Convergence Diagnostics Summary\n\
===============================\n\
Converged: {}\n\
Iterations: {}\n\
Final Residual: {:.3e}\n\
Best Residual: {:.3e}\n\
Solver Switches: {}\n\
Timing: {} ms",
converged_str,
self.iterations,
self.final_residual,
self.best_residual,
switch_count,
self.timing_ms
);
if let Some(cond) = self.jacobian_condition_final {
summary.push_str(&format!("\nJacobian Condition: {:.3e}", cond));
if cond > 1e10 {
summary.push_str(" (WARNING: ill-conditioned)");
}
}
if let Some(ref solver) = self.final_solver {
summary.push_str(&format!("\nFinal Solver: {}", solver));
}
summary
}
/// Dumps diagnostics to the configured output format.
///
/// Returns JSON string if `format` is `Json` or `Both`, suitable for
/// file output or structured logging.
pub fn dump_diagnostics(&self, format: VerboseOutputFormat) -> String {
match format {
VerboseOutputFormat::Log => self.summary(),
VerboseOutputFormat::Json | VerboseOutputFormat::Both => {
serde_json::to_string_pretty(self).unwrap_or_else(|e| {
format!("{{\"error\": \"Failed to serialize diagnostics: {}\"}}", e)
})
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper functions
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -25,7 +25,10 @@ use std::time::{Duration, Instant};
use crate::criteria::ConvergenceCriteria;
use crate::metadata::SimulationMetadata;
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError};
use crate::solver::{
ConvergedState, ConvergenceDiagnostics, ConvergenceStatus, Solver, SolverError,
SolverSwitchEvent, SolverType, SwitchReason, VerboseConfig,
};
use crate::system::System;
use super::{NewtonConfig, PicardConfig};
@@ -39,13 +42,14 @@ use super::{NewtonConfig, PicardConfig};
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{FallbackConfig, FallbackSolver, Solver};
/// use entropyk_solver::solver::{FallbackConfig, FallbackSolver, Solver, VerboseConfig};
/// use std::time::Duration;
///
/// let config = FallbackConfig {
/// fallback_enabled: true,
/// return_to_newton_threshold: 1e-3,
/// max_fallback_switches: 2,
/// verbose_config: VerboseConfig::default(),
/// };
///
/// let solver = FallbackSolver::new(config)
@@ -71,6 +75,9 @@ pub struct FallbackConfig {
/// Prevents infinite oscillation between Newton and Picard.
/// Default: 2.
pub max_fallback_switches: usize,
/// Verbose mode configuration for diagnostics.
pub verbose_config: VerboseConfig,
}
impl Default for FallbackConfig {
@@ -79,6 +86,7 @@ impl Default for FallbackConfig {
fallback_enabled: true,
return_to_newton_threshold: 1e-3,
max_fallback_switches: 2,
verbose_config: VerboseConfig::default(),
}
}
}
@@ -90,6 +98,15 @@ enum CurrentSolver {
Picard,
}
impl From<CurrentSolver> for SolverType {
fn from(solver: CurrentSolver) -> Self {
match solver {
CurrentSolver::Newton => SolverType::NewtonRaphson,
CurrentSolver::Picard => SolverType::SequentialSubstitution,
}
}
}
/// Internal state for the fallback solver.
struct FallbackState {
current_solver: CurrentSolver,
@@ -100,6 +117,10 @@ struct FallbackState {
best_state: Option<Vec<f64>>,
/// Best residual norm across all solver invocations (Story 4.5 - AC: #4)
best_residual: Option<f64>,
/// Total iterations across all solver invocations
total_iterations: usize,
/// Solver switch events for diagnostics (Story 7.4)
switch_events: Vec<SolverSwitchEvent>,
}
impl FallbackState {
@@ -110,6 +131,8 @@ impl FallbackState {
committed_to_picard: false,
best_state: None,
best_residual: None,
total_iterations: 0,
switch_events: Vec::new(),
}
}
@@ -120,6 +143,23 @@ impl FallbackState {
self.best_residual = Some(residual);
}
}
/// Record a solver switch event (Story 7.4)
fn record_switch(
&mut self,
from: CurrentSolver,
to: CurrentSolver,
reason: SwitchReason,
residual_at_switch: f64,
) {
self.switch_events.push(SolverSwitchEvent {
from_solver: from.into(),
to_solver: to.into(),
reason,
iteration: self.total_iterations,
residual_at_switch,
});
}
}
/// Intelligent fallback solver that switches between Newton-Raphson and Picard.
@@ -211,10 +251,23 @@ impl FallbackSolver {
timeout: Option<Duration>,
) -> Result<ConvergedState, SolverError> {
let mut state = FallbackState::new();
// Verbose mode setup
let verbose_enabled = self.config.verbose_config.enabled
&& self.config.verbose_config.is_any_enabled();
let mut diagnostics = if verbose_enabled {
Some(ConvergenceDiagnostics::with_capacity(100))
} else {
None
};
// Pre-configure solver configs once
let mut newton_cfg = self.newton_config.clone();
let mut picard_cfg = self.picard_config.clone();
// Propagate verbose config to child solvers
newton_cfg.verbose_config = self.config.verbose_config.clone();
picard_cfg.verbose_config = self.config.verbose_config.clone();
loop {
// Check remaining time budget
@@ -242,6 +295,27 @@ impl FallbackSolver {
Ok(converged) => {
// Update best state tracking (Story 4.5 - AC: #4)
state.update_best_state(&converged.state, converged.final_residual);
state.total_iterations += converged.iterations;
// Finalize diagnostics
if let Some(ref mut diag) = diagnostics {
diag.iterations = state.total_iterations;
diag.final_residual = converged.final_residual;
diag.best_residual = state.best_residual.unwrap_or(converged.final_residual);
diag.converged = true;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.final_solver = Some(state.current_solver.into());
diag.solver_switches = state.switch_events.clone();
// Merge iteration history from child solver if available
if let Some(ref child_diag) = converged.diagnostics {
diag.iteration_history = child_diag.iteration_history.clone();
}
if self.config.verbose_config.log_residuals {
tracing::info!("{}", diag.summary());
}
}
tracing::info!(
solver = match state.current_solver {
@@ -253,7 +327,11 @@ impl FallbackSolver {
switch_count = state.switch_count,
"Fallback solver converged"
);
return Ok(converged);
// Return with diagnostics if verbose mode enabled
return Ok(if let Some(d) = diagnostics {
ConvergedState { diagnostics: Some(d), ..converged }
} else { converged });
}
Err(SolverError::Timeout { timeout_ms }) => {
// Story 4.5 - AC: #4: Return best state on timeout if available
@@ -266,7 +344,7 @@ impl FallbackSolver {
);
return Ok(ConvergedState::new(
best_state,
0, // iterations not tracked across switches
state.total_iterations,
best_residual,
ConvergenceStatus::TimedOutWithBestState,
SimulationMetadata::new(system.input_hash()),
@@ -290,11 +368,36 @@ impl FallbackSolver {
match state.current_solver {
CurrentSolver::Newton => {
// Get residual from error context (use best known)
let residual_at_switch = state.best_residual.unwrap_or(f64::MAX);
// Newton diverged - switch to Picard (stay there permanently after max switches)
if state.switch_count >= self.config.max_fallback_switches {
// Max switches reached - commit to Picard permanently
state.committed_to_picard = true;
let prev_solver = state.current_solver;
state.current_solver = CurrentSolver::Picard;
// Record switch event
state.record_switch(
prev_solver,
state.current_solver,
SwitchReason::Divergence,
residual_at_switch,
);
// Verbose logging
if verbose_enabled && self.config.verbose_config.log_solver_switches {
tracing::info!(
from = "NewtonRaphson",
to = "Picard",
reason = "divergence",
switch_count = state.switch_count,
residual = residual_at_switch,
"Solver switch (max switches reached)"
);
}
tracing::info!(
switch_count = state.switch_count,
max_switches = self.config.max_fallback_switches,
@@ -303,7 +406,29 @@ impl FallbackSolver {
} else {
// Switch to Picard
state.switch_count += 1;
let prev_solver = state.current_solver;
state.current_solver = CurrentSolver::Picard;
// Record switch event
state.record_switch(
prev_solver,
state.current_solver,
SwitchReason::Divergence,
residual_at_switch,
);
// Verbose logging
if verbose_enabled && self.config.verbose_config.log_solver_switches {
tracing::info!(
from = "NewtonRaphson",
to = "Picard",
reason = "divergence",
switch_count = state.switch_count,
residual = residual_at_switch,
"Solver switch"
);
}
tracing::warn!(
switch_count = state.switch_count,
reason = reason,
@@ -337,6 +462,8 @@ impl FallbackSolver {
iterations,
final_residual,
}) => {
state.total_iterations += iterations;
// Non-convergence: check if we should try the other solver
if !self.config.fallback_enabled {
return Err(SolverError::NonConvergence {
@@ -351,14 +478,58 @@ impl FallbackSolver {
if state.switch_count >= self.config.max_fallback_switches {
// Max switches reached - commit to Picard permanently
state.committed_to_picard = true;
let prev_solver = state.current_solver;
state.current_solver = CurrentSolver::Picard;
// Record switch event
state.record_switch(
prev_solver,
state.current_solver,
SwitchReason::SlowConvergence,
final_residual,
);
// Verbose logging
if verbose_enabled && self.config.verbose_config.log_solver_switches {
tracing::info!(
from = "NewtonRaphson",
to = "Picard",
reason = "slow_convergence",
switch_count = state.switch_count,
residual = final_residual,
"Solver switch (max switches reached)"
);
}
tracing::info!(
switch_count = state.switch_count,
"Max switches reached, committing to Picard permanently"
);
} else {
state.switch_count += 1;
let prev_solver = state.current_solver;
state.current_solver = CurrentSolver::Picard;
// Record switch event
state.record_switch(
prev_solver,
state.current_solver,
SwitchReason::SlowConvergence,
final_residual,
);
// Verbose logging
if verbose_enabled && self.config.verbose_config.log_solver_switches {
tracing::info!(
from = "NewtonRaphson",
to = "Picard",
reason = "slow_convergence",
switch_count = state.switch_count,
residual = final_residual,
"Solver switch"
);
}
tracing::info!(
switch_count = state.switch_count,
iterations = iterations,
@@ -387,7 +558,30 @@ impl FallbackSolver {
// Check if residual is low enough to try Newton
if final_residual < self.config.return_to_newton_threshold {
state.switch_count += 1;
let prev_solver = state.current_solver;
state.current_solver = CurrentSolver::Newton;
// Record switch event
state.record_switch(
prev_solver,
state.current_solver,
SwitchReason::ReturnToNewton,
final_residual,
);
// Verbose logging
if verbose_enabled && self.config.verbose_config.log_solver_switches {
tracing::info!(
from = "Picard",
to = "NewtonRaphson",
reason = "return_to_newton",
switch_count = state.switch_count,
residual = final_residual,
threshold = self.config.return_to_newton_threshold,
"Solver switch (Picard stabilized)"
);
}
tracing::info!(
switch_count = state.switch_count,
final_residual = final_residual,
@@ -467,9 +661,12 @@ mod tests {
fallback_enabled: false,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
..Default::default()
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
assert_eq!(solver.config.fallback_enabled, config.fallback_enabled);
assert_eq!(solver.config.return_to_newton_threshold, 5e-4);
assert_eq!(solver.config.max_fallback_switches, 3);
}
#[test]

View File

@@ -9,8 +9,9 @@ use crate::criteria::ConvergenceCriteria;
use crate::jacobian::JacobianMatrix;
use crate::metadata::SimulationMetadata;
use crate::solver::{
apply_newton_step, ConvergedState, ConvergenceStatus, JacobianFreezingConfig, Solver,
SolverError, TimeoutConfig,
apply_newton_step, ConvergedState, ConvergenceDiagnostics, ConvergenceStatus,
IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError, SolverType,
TimeoutConfig, VerboseConfig,
};
use crate::system::System;
use entropyk_components::JacobianBuilder;
@@ -49,6 +50,8 @@ pub struct NewtonConfig {
pub convergence_criteria: Option<ConvergenceCriteria>,
/// Jacobian-freezing optimization.
pub jacobian_freezing: Option<JacobianFreezingConfig>,
/// Verbose mode configuration for diagnostics.
pub verbose_config: VerboseConfig,
}
impl Default for NewtonConfig {
@@ -68,6 +71,7 @@ impl Default for NewtonConfig {
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
verbose_config: VerboseConfig::default(),
}
}
}
@@ -91,6 +95,12 @@ impl NewtonConfig {
self
}
/// Enables verbose mode for diagnostics.
pub fn with_verbose(mut self, config: VerboseConfig) -> Self {
self.verbose_config = config;
self
}
/// Computes the L2 norm of the residual vector.
fn residual_norm(residuals: &[f64]) -> f64 {
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
@@ -208,10 +218,19 @@ impl Solver for NewtonConfig {
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
let start_time = Instant::now();
// Initialize diagnostics collection if verbose mode enabled
let verbose_enabled = self.verbose_config.enabled && self.verbose_config.is_any_enabled();
let mut diagnostics = if verbose_enabled {
Some(ConvergenceDiagnostics::with_capacity(self.max_iterations))
} else {
None
};
tracing::info!(
max_iterations = self.max_iterations,
tolerance = self.tolerance,
line_search = self.line_search,
verbose = verbose_enabled,
"Newton-Raphson solver starting"
);
@@ -254,6 +273,9 @@ impl Solver for NewtonConfig {
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
let mut frozen_count: usize = 0;
let mut force_recompute: bool = true;
// Cached condition number (for verbose mode when Jacobian frozen)
let mut cached_condition: Option<f64> = None;
// Pre-compute clipping mask
let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
@@ -323,6 +345,8 @@ impl Solver for NewtonConfig {
true
};
let jacobian_frozen_this_iter = !should_recompute;
if should_recompute {
// Fresh Jacobian assembly (in-place update)
jacobian_builder.clear();
@@ -350,6 +374,19 @@ impl Solver for NewtonConfig {
frozen_count = 0;
force_recompute = false;
// Compute and cache condition number if verbose mode enabled
if verbose_enabled && self.verbose_config.log_jacobian_condition {
let cond = jacobian_matrix.estimate_condition_number();
cached_condition = cond;
if let Some(c) = cond {
tracing::info!(iteration, condition_number = c, "Jacobian condition number");
if c > 1e10 {
tracing::warn!(iteration, condition_number = c, "Ill-conditioned Jacobian detected (κ > 1e10)");
}
}
}
tracing::debug!(iteration, "Fresh Jacobian computed");
} else {
frozen_count += 1;
@@ -391,6 +428,13 @@ impl Solver for NewtonConfig {
previous_norm = current_norm;
current_norm = Self::residual_norm(&residuals);
// Compute delta norm for diagnostics
let delta_norm: f64 = state.iter()
.zip(prev_iteration_state.iter())
.map(|(s, p)| (s - p).powi(2))
.sum::<f64>()
.sqrt();
if current_norm < best_residual {
best_state.copy_from_slice(&state);
@@ -409,6 +453,30 @@ impl Solver for NewtonConfig {
}
}
// Verbose mode: Log iteration residuals
if verbose_enabled && self.verbose_config.log_residuals {
tracing::info!(
iteration,
residual_norm = current_norm,
delta_norm = delta_norm,
alpha = alpha,
jacobian_frozen = jacobian_frozen_this_iter,
"Newton iteration"
);
}
// Collect iteration diagnostics
if let Some(ref mut diag) = diagnostics {
diag.push_iteration(IterationDiagnostics {
iteration,
residual_norm: current_norm,
delta_norm,
alpha: Some(alpha),
jacobian_frozen: jacobian_frozen_this_iter,
jacobian_condition: cached_condition,
});
}
tracing::debug!(iteration, residual_norm = current_norm, alpha, "Newton iteration complete");
// Check convergence
@@ -420,10 +488,29 @@ impl Solver for NewtonConfig {
} else {
ConvergenceStatus::Converged
};
// Finalize diagnostics
if let Some(ref mut diag) = diagnostics {
diag.iterations = iteration;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.jacobian_condition_final = cached_condition;
diag.final_solver = Some(SolverType::NewtonRaphson);
if self.verbose_config.log_residuals {
tracing::info!("{}", diag.summary());
}
}
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged (criteria)");
return Ok(ConvergedState::with_report(
let result = ConvergedState::with_report(
state, iteration, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
));
);
return Ok(if let Some(d) = diagnostics {
ConvergedState { diagnostics: Some(d), ..result }
} else { result });
}
false
} else {
@@ -436,10 +523,29 @@ impl Solver for NewtonConfig {
} else {
ConvergenceStatus::Converged
};
// Finalize diagnostics
if let Some(ref mut diag) = diagnostics {
diag.iterations = iteration;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.jacobian_condition_final = cached_condition;
diag.final_solver = Some(SolverType::NewtonRaphson);
if self.verbose_config.log_residuals {
tracing::info!("{}", diag.summary());
}
}
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged");
return Ok(ConvergedState::new(
let result = ConvergedState::new(
state, iteration, current_norm, status, SimulationMetadata::new(system.input_hash()),
));
);
return Ok(if let Some(d) = diagnostics {
ConvergedState { diagnostics: Some(d), ..result }
} else { result });
}
if let Some(err) = self.check_divergence(current_norm, previous_norm, &mut divergence_count) {
@@ -448,6 +554,28 @@ impl Solver for NewtonConfig {
}
}
// Non-convergence: dump diagnostics if enabled
if let Some(ref mut diag) = diagnostics {
diag.iterations = self.max_iterations;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = false;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.jacobian_condition_final = cached_condition;
diag.final_solver = Some(SolverType::NewtonRaphson);
if self.verbose_config.dump_final_state {
diag.final_state = Some(state.clone());
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
tracing::warn!(
iterations = self.max_iterations,
final_residual = current_norm,
"Non-convergence diagnostics:\n{}",
json_output
);
}
}
tracing::warn!(max_iterations = self.max_iterations, final_residual = current_norm, "Did not converge");
Err(SolverError::NonConvergence {
iterations: self.max_iterations,

View File

@@ -7,7 +7,10 @@ use std::time::{Duration, Instant};
use crate::criteria::ConvergenceCriteria;
use crate::metadata::SimulationMetadata;
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError, TimeoutConfig};
use crate::solver::{
ConvergedState, ConvergenceDiagnostics, ConvergenceStatus, IterationDiagnostics, Solver,
SolverError, SolverType, TimeoutConfig, VerboseConfig,
};
use crate::system::System;
/// Configuration for the Sequential Substitution (Picard iteration) solver.
@@ -38,6 +41,8 @@ pub struct PicardConfig {
pub initial_state: Option<Vec<f64>>,
/// Multi-circuit convergence criteria.
pub convergence_criteria: Option<ConvergenceCriteria>,
/// Verbose mode configuration for diagnostics.
pub verbose_config: VerboseConfig,
}
impl Default for PicardConfig {
@@ -54,6 +59,7 @@ impl Default for PicardConfig {
previous_residual: None,
initial_state: None,
convergence_criteria: None,
verbose_config: VerboseConfig::default(),
}
}
}
@@ -78,6 +84,12 @@ impl PicardConfig {
self
}
/// Enables verbose mode for diagnostics.
pub fn with_verbose(mut self, config: VerboseConfig) -> Self {
self.verbose_config = config;
self
}
/// Computes the residual norm (L2 norm of the residual vector).
fn residual_norm(residuals: &[f64]) -> f64 {
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
@@ -194,12 +206,21 @@ impl Solver for PicardConfig {
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
let start_time = Instant::now();
// Initialize diagnostics collection if verbose mode enabled
let verbose_enabled = self.verbose_config.enabled && self.verbose_config.is_any_enabled();
let mut diagnostics = if verbose_enabled {
Some(ConvergenceDiagnostics::with_capacity(self.max_iterations))
} else {
None
};
tracing::info!(
max_iterations = self.max_iterations,
tolerance = self.tolerance,
relaxation_factor = self.relaxation_factor,
divergence_threshold = self.divergence_threshold,
divergence_patience = self.divergence_patience,
verbose = verbose_enabled,
"Sequential Substitution (Picard) solver starting"
);
@@ -328,6 +349,13 @@ impl Solver for PicardConfig {
previous_norm = current_norm;
current_norm = Self::residual_norm(&residuals);
// Compute delta norm for diagnostics
let delta_norm: f64 = state.iter()
.zip(prev_iteration_state.iter())
.map(|(s, p)| (s - p).powi(2))
.sum::<f64>()
.sqrt();
// Update best state if residual improved (Story 4.5 - AC: #2)
if current_norm < best_residual {
@@ -340,6 +368,29 @@ impl Solver for PicardConfig {
);
}
// Verbose mode: Log iteration residuals
if verbose_enabled && self.verbose_config.log_residuals {
tracing::info!(
iteration,
residual_norm = current_norm,
delta_norm = delta_norm,
relaxation_factor = self.relaxation_factor,
"Picard iteration"
);
}
// Collect iteration diagnostics
if let Some(ref mut diag) = diagnostics {
diag.push_iteration(IterationDiagnostics {
iteration,
residual_norm: current_norm,
delta_norm,
alpha: None, // Picard doesn't use line search
jacobian_frozen: false, // Picard doesn't use Jacobian
jacobian_condition: None, // No Jacobian in Picard
});
}
tracing::debug!(
iteration = iteration,
residual_norm = current_norm,
@@ -352,20 +403,37 @@ impl Solver for PicardConfig {
let report =
criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
if report.is_globally_converged() {
// Finalize diagnostics
if let Some(ref mut diag) = diagnostics {
diag.iterations = iteration;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.final_solver = Some(SolverType::SequentialSubstitution);
if self.verbose_config.log_residuals {
tracing::info!("{}", diag.summary());
}
}
tracing::info!(
iterations = iteration,
final_residual = current_norm,
relaxation_factor = self.relaxation_factor,
"Sequential Substitution converged (criteria)"
);
return Ok(ConvergedState::with_report(
let result = ConvergedState::with_report(
state,
iteration,
current_norm,
ConvergenceStatus::Converged,
report,
SimulationMetadata::new(system.input_hash()),
));
);
return Ok(if let Some(d) = diagnostics {
ConvergedState { diagnostics: Some(d), ..result }
} else { result });
}
false
} else {
@@ -373,19 +441,36 @@ impl Solver for PicardConfig {
};
if converged {
// Finalize diagnostics
if let Some(ref mut diag) = diagnostics {
diag.iterations = iteration;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.final_solver = Some(SolverType::SequentialSubstitution);
if self.verbose_config.log_residuals {
tracing::info!("{}", diag.summary());
}
}
tracing::info!(
iterations = iteration,
final_residual = current_norm,
relaxation_factor = self.relaxation_factor,
"Sequential Substitution converged"
);
return Ok(ConvergedState::new(
let result = ConvergedState::new(
state,
iteration,
current_norm,
ConvergenceStatus::Converged,
SimulationMetadata::new(system.input_hash()),
));
);
return Ok(if let Some(d) = diagnostics {
ConvergedState { diagnostics: Some(d), ..result }
} else { result });
}
// Check divergence (AC: #5)
@@ -401,6 +486,27 @@ impl Solver for PicardConfig {
}
}
// Non-convergence: dump diagnostics if enabled
if let Some(ref mut diag) = diagnostics {
diag.iterations = self.max_iterations;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = false;
diag.timing_ms = start_time.elapsed().as_millis() as u64;
diag.final_solver = Some(SolverType::SequentialSubstitution);
if self.verbose_config.dump_final_state {
diag.final_state = Some(state.clone());
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
tracing::warn!(
iterations = self.max_iterations,
final_residual = current_norm,
"Non-convergence diagnostics:\n{}",
json_output
);
}
}
// Max iterations exceeded
tracing::warn!(
max_iterations = self.max_iterations,