491 lines
20 KiB
Rust
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());
|
|
}
|
|
}
|