Files
Entropyk/crates/solver/src/strategies/fallback.rs

491 lines
20 KiB
Rust

//! Intelligent fallback solver implementation.
//!
//! This module provides the [`FallbackSolver`] which implements an intelligent
//! fallback strategy between Newton-Raphson and Sequential Substitution (Picard).
//!
//! # Strategy
//!
//! The fallback solver implements the following algorithm:
//!
//! 1. Start with Newton-Raphson (quadratic convergence)
//! 2. If Newton diverges, switch to Picard (more robust)
//! 3. If Picard stabilizes (residual < threshold), try returning to Newton
//! 4. If max switches reached, stay on current solver permanently
//!
//! # Features
//!
//! - Automatic fallback from Newton to Picard on divergence
//! - Return to Newton when Picard stabilizes the solution
//! - Maximum switch limit to prevent infinite oscillation
//! - Time-budgeted solving with graceful degradation (Story 4.5)
//! - Smart initialization support (Story 4.6)
//! - Multi-circuit convergence criteria (Story 4.7)
use std::time::{Duration, Instant};
use crate::criteria::ConvergenceCriteria;
use crate::metadata::SimulationMetadata;
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError};
use crate::system::System;
use super::{NewtonConfig, PicardConfig};
/// Configuration for the intelligent fallback solver.
///
/// The fallback solver starts with Newton-Raphson (quadratic convergence) and
/// automatically switches to Sequential Substitution (Picard) if Newton diverges.
/// It can return to Newton when Picard stabilizes the solution.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{FallbackConfig, FallbackSolver, Solver};
/// use std::time::Duration;
///
/// let config = FallbackConfig {
/// fallback_enabled: true,
/// return_to_newton_threshold: 1e-3,
/// max_fallback_switches: 2,
/// };
///
/// let solver = FallbackSolver::new(config)
/// .with_timeout(Duration::from_secs(1));
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct FallbackConfig {
/// Enable automatic fallback from Newton to Picard on divergence.
///
/// When `true` (default), the solver switches to Picard if Newton diverges.
/// When `false`, the solver runs pure Newton or Picard without fallback.
pub fallback_enabled: bool,
/// Residual norm threshold for returning to Newton from Picard.
///
/// When Picard reduces the residual below this threshold, the solver
/// attempts to return to Newton for faster convergence.
/// Default: $10^{-3}$.
pub return_to_newton_threshold: f64,
/// Maximum number of solver switches before staying on current solver.
///
/// Prevents infinite oscillation between Newton and Picard.
/// Default: 2.
pub max_fallback_switches: usize,
}
impl Default for FallbackConfig {
fn default() -> Self {
Self {
fallback_enabled: true,
return_to_newton_threshold: 1e-3,
max_fallback_switches: 2,
}
}
}
/// Tracks which solver is currently active in the fallback loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CurrentSolver {
Newton,
Picard,
}
/// Internal state for the fallback solver.
struct FallbackState {
current_solver: CurrentSolver,
switch_count: usize,
/// Whether we've permanently committed to Picard (after max switches or Newton re-divergence)
committed_to_picard: bool,
/// Best state encountered across all solver invocations (Story 4.5 - AC: #4)
best_state: Option<Vec<f64>>,
/// Best residual norm across all solver invocations (Story 4.5 - AC: #4)
best_residual: Option<f64>,
}
impl FallbackState {
fn new() -> Self {
Self {
current_solver: CurrentSolver::Newton,
switch_count: 0,
committed_to_picard: false,
best_state: None,
best_residual: None,
}
}
/// Update best state if the given residual is better (Story 4.5 - AC: #4).
fn update_best_state(&mut self, state: &[f64], residual: f64) {
if self.best_residual.is_none() || residual < self.best_residual.unwrap() {
self.best_state = Some(state.to_vec());
self.best_residual = Some(residual);
}
}
}
/// Intelligent fallback solver that switches between Newton-Raphson and Picard.
///
/// The fallback solver implements the following algorithm:
///
/// 1. Start with Newton-Raphson (quadratic convergence)
/// 2. If Newton diverges, switch to Picard (more robust)
/// 3. If Picard stabilizes (residual < threshold), try returning to Newton
/// 4. If max switches reached, stay on current solver permanently
///
/// # Timeout Handling
///
/// The timeout applies to the total solving time across all solver switches.
/// Each solver inherits the remaining time budget.
///
/// # Pre-Allocated Buffers
///
/// All buffers are pre-allocated once before the fallback loop to avoid
/// heap allocation during solver switches (NFR4).
#[derive(Debug, Clone)]
pub struct FallbackSolver {
/// Fallback behavior configuration.
pub config: FallbackConfig,
/// Newton-Raphson configuration.
pub newton_config: NewtonConfig,
/// Sequential Substitution (Picard) configuration.
pub picard_config: PicardConfig,
}
impl FallbackSolver {
/// Creates a new fallback solver with the given configuration.
pub fn new(config: FallbackConfig) -> Self {
Self {
config,
newton_config: NewtonConfig::default(),
picard_config: PicardConfig::default(),
}
}
/// Creates a fallback solver with default configuration.
pub fn default_solver() -> Self {
Self::new(FallbackConfig::default())
}
/// Sets custom Newton-Raphson configuration.
pub fn with_newton_config(mut self, config: NewtonConfig) -> Self {
self.newton_config = config;
self
}
/// Sets custom Picard configuration.
pub fn with_picard_config(mut self, config: PicardConfig) -> Self {
self.picard_config = config;
self
}
/// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
///
/// Delegates to both `newton_config` and `picard_config` so the initial state
/// is used regardless of which solver is active in the fallback loop.
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
self.newton_config.initial_state = Some(state.clone());
self.picard_config.initial_state = Some(state);
self
}
/// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
///
/// Delegates to both `newton_config` and `picard_config` so criteria are
/// applied regardless of which solver is active in the fallback loop.
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
self.newton_config.convergence_criteria = Some(criteria.clone());
self.picard_config.convergence_criteria = Some(criteria);
self
}
/// Main fallback solving loop.
///
/// Implements the intelligent fallback algorithm:
/// - Start with Newton-Raphson
/// - Switch to Picard on Newton divergence
/// - Return to Newton when Picard stabilizes (if under switch limit and residual below threshold)
/// - Stay on Picard permanently after max switches or if Newton re-diverges
fn solve_with_fallback(
&mut self,
system: &mut System,
start_time: Instant,
timeout: Option<Duration>,
) -> Result<ConvergedState, SolverError> {
let mut state = FallbackState::new();
// Pre-configure solver configs once
let mut newton_cfg = self.newton_config.clone();
let mut picard_cfg = self.picard_config.clone();
loop {
// Check remaining time budget
let remaining = timeout.map(|t| t.saturating_sub(start_time.elapsed()));
// Check for timeout before running solver
if let Some(remaining_time) = remaining {
if remaining_time.is_zero() {
return Err(SolverError::Timeout {
timeout_ms: timeout.unwrap().as_millis() as u64,
});
}
}
// Run current solver with remaining time
newton_cfg.timeout = remaining;
picard_cfg.timeout = remaining;
let result = match state.current_solver {
CurrentSolver::Newton => newton_cfg.solve(system),
CurrentSolver::Picard => picard_cfg.solve(system),
};
match result {
Ok(converged) => {
// Update best state tracking (Story 4.5 - AC: #4)
state.update_best_state(&converged.state, converged.final_residual);
tracing::info!(
solver = match state.current_solver {
CurrentSolver::Newton => "NewtonRaphson",
CurrentSolver::Picard => "Picard",
},
iterations = converged.iterations,
final_residual = converged.final_residual,
switch_count = state.switch_count,
"Fallback solver converged"
);
return Ok(converged);
}
Err(SolverError::Timeout { timeout_ms }) => {
// Story 4.5 - AC: #4: Return best state on timeout if available
if let (Some(best_state), Some(best_residual)) =
(state.best_state.clone(), state.best_residual)
{
tracing::info!(
best_residual = best_residual,
"Returning best state across all solver invocations on timeout"
);
return Ok(ConvergedState::new(
best_state,
0, // iterations not tracked across switches
best_residual,
ConvergenceStatus::TimedOutWithBestState,
SimulationMetadata::new(system.input_hash()),
));
}
return Err(SolverError::Timeout { timeout_ms });
}
Err(SolverError::Divergence { ref reason }) => {
// Handle divergence based on current solver and state
if !self.config.fallback_enabled {
tracing::info!(
solver = match state.current_solver {
CurrentSolver::Newton => "NewtonRaphson",
CurrentSolver::Picard => "Picard",
},
reason = reason,
"Divergence detected, fallback disabled"
);
return result;
}
match state.current_solver {
CurrentSolver::Newton => {
// 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;
state.current_solver = CurrentSolver::Picard;
tracing::info!(
switch_count = state.switch_count,
max_switches = self.config.max_fallback_switches,
"Max switches reached, committing to Picard permanently"
);
} else {
// Switch to Picard
state.switch_count += 1;
state.current_solver = CurrentSolver::Picard;
tracing::warn!(
switch_count = state.switch_count,
reason = reason,
"Newton diverged, switching to Picard"
);
}
// Continue loop with Picard
}
CurrentSolver::Picard => {
// Picard diverged - if we were trying Newton again, commit to Picard permanently
if state.switch_count > 0 && !state.committed_to_picard {
state.committed_to_picard = true;
tracing::info!(
switch_count = state.switch_count,
reason = reason,
"Newton re-diverged after return from Picard, staying on Picard permanently"
);
// Stay on Picard and try again
} else {
// Picard diverged with no return attempt - no more fallbacks available
tracing::warn!(
reason = reason,
"Picard diverged, no more fallbacks available"
);
return result;
}
}
}
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// Non-convergence: check if we should try the other solver
if !self.config.fallback_enabled {
return Err(SolverError::NonConvergence {
iterations,
final_residual,
});
}
match state.current_solver {
CurrentSolver::Newton => {
// Newton didn't converge - try Picard
if state.switch_count >= self.config.max_fallback_switches {
// Max switches reached - commit to Picard permanently
state.committed_to_picard = true;
state.current_solver = CurrentSolver::Picard;
tracing::info!(
switch_count = state.switch_count,
"Max switches reached, committing to Picard permanently"
);
} else {
state.switch_count += 1;
state.current_solver = CurrentSolver::Picard;
tracing::info!(
switch_count = state.switch_count,
iterations = iterations,
final_residual = final_residual,
"Newton did not converge, switching to Picard"
);
}
// Continue loop with Picard
}
CurrentSolver::Picard => {
// Picard didn't converge - check if we should try Newton
if state.committed_to_picard
|| state.switch_count >= self.config.max_fallback_switches
{
tracing::info!(
iterations = iterations,
final_residual = final_residual,
"Picard did not converge, no more fallbacks"
);
return Err(SolverError::NonConvergence {
iterations,
final_residual,
});
}
// Check if residual is low enough to try Newton
if final_residual < self.config.return_to_newton_threshold {
state.switch_count += 1;
state.current_solver = CurrentSolver::Newton;
tracing::info!(
switch_count = state.switch_count,
final_residual = final_residual,
threshold = self.config.return_to_newton_threshold,
"Picard stabilized, attempting Newton return"
);
// Continue loop with Newton
} else {
// Stay on Picard and keep trying
tracing::debug!(
final_residual = final_residual,
threshold = self.config.return_to_newton_threshold,
"Picard not yet stabilized, aborting"
);
return Err(SolverError::NonConvergence {
iterations,
final_residual,
});
}
}
}
}
Err(other) => {
// InvalidSystem or other errors - propagate immediately
return Err(other);
}
}
}
}
}
impl Solver for FallbackSolver {
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
let start_time = Instant::now();
let timeout = self.newton_config.timeout.or(self.picard_config.timeout);
tracing::info!(
fallback_enabled = self.config.fallback_enabled,
return_to_newton_threshold = self.config.return_to_newton_threshold,
max_fallback_switches = self.config.max_fallback_switches,
"Fallback solver starting"
);
if self.config.fallback_enabled {
self.solve_with_fallback(system, start_time, timeout)
} else {
// Fallback disabled - run pure Newton
self.newton_config.solve(system)
}
}
fn with_timeout(mut self, timeout: Duration) -> Self {
self.newton_config.timeout = Some(timeout);
self.picard_config.timeout = Some(timeout);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::solver::Solver;
use crate::system::System;
use std::time::Duration;
#[test]
fn test_fallback_config_defaults() {
let cfg = FallbackConfig::default();
assert!(cfg.fallback_enabled);
assert!((cfg.return_to_newton_threshold - 1e-3).abs() < 1e-15);
assert_eq!(cfg.max_fallback_switches, 2);
}
#[test]
fn test_fallback_solver_new() {
let config = FallbackConfig {
fallback_enabled: false,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
}
#[test]
fn test_fallback_solver_with_timeout() {
let timeout = Duration::from_millis(500);
let solver = FallbackSolver::default_solver().with_timeout(timeout);
assert_eq!(solver.newton_config.timeout, Some(timeout));
assert_eq!(solver.picard_config.timeout, Some(timeout));
}
#[test]
fn test_fallback_solver_trait_object() {
let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
let mut system = System::new();
system.finalize().unwrap();
assert!(boxed.solve(&mut system).is_err());
}
}