Skip to main content

alkahest_cas/ode/dsolve/
system.rs

1//! Linear constant-coefficient **systems** `y' = A·y + f(t)`.
2//!
3//! [`super::dsolve`] is scalar by construction — its input is one equation in
4//! one unknown — so a two-compartment pharmacokinetic model
5//! (`x' = −ka·x`, `y' = ka·x − ke·y`) or any state-space plant had no route
6//! through it at all.  This module takes the [`ODE`] type the rest of the crate
7//! already uses to model `d(state)/dt = rhs` and solves it in closed form when
8//! the right-hand side is linear in the state with coefficients free of `t`.
9//!
10//! # Why a separate entry point rather than an overload of `dsolve`
11//!
12//! [`super::dsolve`]'s [`OdeInput`](super::OdeInput) names *one* unknown `y`
13//! and a list of derivative symbols `y', y'', …` of that one unknown.  A system
14//! has `n` unknowns and one derivative each; there is no reading of `OdeInput`
15//! under which the two coincide, and widening it would mean a second
16//! interpretation of `derivs` selected by a flag.  [`ODE`] already models
17//! exactly the shape needed (`state_vars`, `rhs`, `time_var`), is already
18//! public, and is already what the numeric integrators consume — so a system
19//! solved here and a system integrated by `ode_integrate_rk45` are the same
20//! object, which they would not be under an overload.
21//!
22//! # Method: Putzer, not Jordan
23//!
24//! The fundamental matrix is `Φ(t) = e^{At}`, computed by **Putzer's
25//! algorithm**:
26//!
27//! ```text
28//! e^{At} = Σ_{k=1}^{n} r_k(t)·P_{k−1},
29//!     P_0 = I,  P_k = (A − λ_k I)·P_{k−1},
30//!     r_1' = λ_1 r_1,  r_1(0) = 1,
31//!     r_{k+1}' = λ_{k+1} r_{k+1} + r_k,  r_{k+1}(0) = 0.
32//! ```
33//!
34//! It needs the eigenvalues **and nothing else** — no eigenvectors, no
35//! nullspaces, no similarity transform.  That matters twice over:
36//!
37//! * A **defective** `A` costs nothing extra.  For `[[2,1],[0,2]]` the
38//!   eigenvalue list is `2, 2`, the recurrence gives `r_1 = e^{2t}`,
39//!   `r_2 = t·e^{2t}`, and `e^{At} = e^{2t}I + t·e^{2t}(A − 2I)` is
40//!   `[[e^{2t}, t·e^{2t}], [0, e^{2t}]]` — the Jordan answer, with no Jordan
41//!   machinery and no `jordan_form` refusal to route around.
42//! * Every eigenvector routine in the crate has to decide whether a candidate
43//!   pivot vanishes, and over symbolic entries that question is undecidable
44//!   (see `crate::matrix::zero_test`).  Putzer moves the only such question
45//!   to one place — `λ_i − λ_j`, in the recurrence for `r_{k+1}` — where it can
46//!   be reported instead of guessed.
47//!
48//! `r_k` is kept as an explicit `Σ c·t^j·e^{λt}` list rather than being handed
49//! to the integration engine, so the recurrence is exact term by term and the
50//! `λ_i = λ_j` confluence is a visible branch rather than a failed integral.
51//!
52//! # The branch problem
53//!
54//! When `λ_i − λ_j` is neither provably zero nor a non-zero literal — `ka − ke`
55//! in the compartment model — the generic formula divides by it.  The result is
56//! then **undefined**, not merely non-general, at the confluence, so the
57//! condition travels with the answer in
58//! [`SystemSolution::side_conditions`] and the confluent limit is named in
59//! [`SystemSolution::notes`].  This is a stronger caveat than the scalar
60//! module's: there the two-exponential form still solves the equation at the
61//! repeated root and only stops spanning, here the expression has a removable
62//! singularity that this solver does not remove.
63//!
64//! # Verification
65//!
66//! Every component of the returned solution is substituted back into *its own*
67//! equation and required to reduce to zero — symbolically, or numerically over
68//! ℂ at sampled `t`, integration constants and free parameters, with the
69//! finite / non-finite / unevaluable classification the scalar gate uses.  A
70//! candidate that does not verify is withheld.
71
72// Matrix and per-equation loops index several parallel structures at once
73// (`A`, `P`, the candidate, its derivative and the system's own right-hand
74// side), so an index is the clearer spelling.  `matrix::eigen` carries the same
75// allow for the same reason.
76#![allow(clippy::needless_range_loop)]
77
78use super::verify::{eval_complex, C64, CONST_SETS, PARAM_SETS, X_SAMPLES};
79use super::{contains, ddx, integrate_or_decline, simp, simp_plain, sub, AssumedSign, SolveCtx};
80use crate::deriv::SideCondition;
81use crate::eval::symbols::collect_free_symbols;
82use crate::kernel::eval_const::try_expr_f64;
83use crate::kernel::{ExprData, ExprId, ExprPool};
84use crate::matrix::putzer::{self, Gap, GapPolicy};
85use crate::matrix::zero_test::{zero_status, ZeroStatus};
86use crate::matrix::Matrix;
87use crate::ode::ODE;
88use crate::simplify::assumptions::AssumptionContext;
89use std::collections::{HashMap, HashSet};
90use std::fmt;
91
92// ---------------------------------------------------------------------------
93// Public API
94// ---------------------------------------------------------------------------
95
96/// The general solution of a linear constant-coefficient system.
97#[derive(Clone, Debug)]
98pub struct SystemSolution {
99    /// The state variables, in the order the [`ODE`] listed them.
100    pub state_vars: Vec<ExprId>,
101    /// `y_of_t[i]` is the closed form of `state_vars[i]` as a function of `t`.
102    pub y_of_t: Vec<ExprId>,
103    /// The fresh constants `C1 … Cn` appearing in [`Self::y_of_t`].
104    pub constants: Vec<ExprId>,
105    /// The fundamental matrix `e^{At}`, row-major, `n × n`.
106    pub fundamental_matrix: Vec<Vec<ExprId>>,
107    /// Short label of the route taken.
108    pub method: &'static str,
109    /// Conditions under which [`Self::y_of_t`] is defined and general.
110    pub side_conditions: Vec<SideCondition>,
111    /// Prose for the cases [`Self::side_conditions`] excludes.
112    pub notes: Vec<String>,
113}
114
115/// Errors / declines from [`dsolve_system`].
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum DsolveSystemError {
118    /// A right-hand side is not affine in the state variables.
119    NotLinear(String),
120    /// A coefficient of the state depends on the independent variable, so the
121    /// system is `y' = A(t)·y` and `e^{At}` is not its fundamental matrix.
122    NonConstantCoefficient(String),
123    /// The eigenvalues of `A` are not available in closed form.
124    UnsupportedSpectrum(String),
125    /// The forcing term could not be closed (a required integral is not
126    /// elementary), or some other step declined.
127    Unsupported(String),
128    /// A candidate was produced and failed the substitution gate, so it is
129    /// withheld rather than returned.
130    VerificationFailed(String),
131}
132
133impl fmt::Display for DsolveSystemError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            DsolveSystemError::NotLinear(m) => {
137                write!(f, "dsolve_system: system is not linear in the state: {m}")
138            }
139            DsolveSystemError::NonConstantCoefficient(m) => write!(
140                f,
141                "dsolve_system: coefficient depends on the independent variable: {m}"
142            ),
143            DsolveSystemError::UnsupportedSpectrum(m) => {
144                write!(f, "dsolve_system: no closed-form spectrum: {m}")
145            }
146            DsolveSystemError::Unsupported(m) => write!(f, "dsolve_system: unsupported: {m}"),
147            DsolveSystemError::VerificationFailed(m) => {
148                write!(f, "dsolve_system: candidate failed verification: {m}")
149            }
150        }
151    }
152}
153
154impl std::error::Error for DsolveSystemError {}
155
156impl crate::errors::AlkahestError for DsolveSystemError {
157    fn code(&self) -> &'static str {
158        match self {
159            DsolveSystemError::NotLinear(_) => "E-ODE-030",
160            DsolveSystemError::NonConstantCoefficient(_) => "E-ODE-031",
161            DsolveSystemError::UnsupportedSpectrum(_) => "E-ODE-032",
162            DsolveSystemError::Unsupported(_) => "E-ODE-033",
163            DsolveSystemError::VerificationFailed(_) => "E-ODE-034",
164        }
165    }
166
167    fn remediation(&self) -> Option<&'static str> {
168        match self {
169            DsolveSystemError::NotLinear(_) => Some(
170                "each right-hand side must be affine in the state variables; \
171                 nonlinear systems have no closed-form matrix exponential",
172            ),
173            DsolveSystemError::NonConstantCoefficient(_) => Some(
174                "y' = A(t)·y needs a Magnus/Peano series, not e^{At}; freeze the \
175                 coefficients or use a numeric integrator",
176            ),
177            DsolveSystemError::UnsupportedSpectrum(_) => Some(
178                "the characteristic polynomial of A has no closed-form roots here; \
179                 substitute concrete values for the symbolic entries, or reduce the \
180                 system to 2×2/3×3 or a triangular shape",
181            ),
182            DsolveSystemError::Unsupported(_) => Some(
183                "a required integral of the forcing term did not close in elementary \
184                 form; no unevaluated-integral output is returned",
185            ),
186            DsolveSystemError::VerificationFailed(_) => Some(
187                "the solver found a candidate that did not verify by substitution; \
188                 this is reported rather than returned as a (possibly wrong) answer",
189            ),
190        }
191    }
192}
193
194/// Solve `y' = A·y + f(t)` in closed form.
195///
196/// See the [module docs](self) for the method and for how a parameter-dependent
197/// eigenvalue confluence is reported.
198///
199/// # Errors
200///
201/// See [`DsolveSystemError`].  Every returned solution has been verified by
202/// substitution into every equation of the system.
203pub fn dsolve_system(ode: &ODE, pool: &ExprPool) -> Result<SystemSolution, DsolveSystemError> {
204    dsolve_system_with(ode, &AssumptionContext::new(), pool)
205}
206
207/// [`dsolve_system`], consulting caller-stated facts before assuming a branch.
208///
209/// A stated `λ_i − λ_j ≠ 0` (or any fact pinning it up to a non-zero constant
210/// multiple) removes the corresponding side condition.
211///
212/// # Errors
213///
214/// See [`DsolveSystemError`].
215pub fn dsolve_system_with(
216    ode: &ODE,
217    assumptions: &AssumptionContext,
218    pool: &ExprPool,
219) -> Result<SystemSolution, DsolveSystemError> {
220    let mut ctx = SolveCtx {
221        assumptions,
222        conds: super::Conditions::default(),
223    };
224    solve_inner(ode, &mut ctx, pool)
225}
226
227// ---------------------------------------------------------------------------
228// Extraction: y' = A·y + f(t)
229// ---------------------------------------------------------------------------
230
231/// Split each `rhs_i` into `Σ_j A[i][j]·y_j + f_i(t)`.
232fn extract_linear_system(
233    ode: &ODE,
234    pool: &ExprPool,
235) -> Result<(Matrix, Vec<ExprId>), DsolveSystemError> {
236    let n = ode.state_vars.len();
237    let t = ode.time_var;
238    let mut rows: Vec<Vec<ExprId>> = Vec::with_capacity(n);
239    let mut forcing: Vec<ExprId> = Vec::with_capacity(n);
240
241    for &r in &ode.rhs {
242        let mut row = Vec::with_capacity(n);
243        for &v in &ode.state_vars {
244            let c = ddx(r, v, pool).map_err(|e| DsolveSystemError::NotLinear(e.to_string()))?;
245            // A coefficient still mentioning a state variable means the row is
246            // not affine in the state.
247            for &w in &ode.state_vars {
248                if contains(c, w, pool) {
249                    return Err(DsolveSystemError::NotLinear(format!(
250                        "∂({})/∂({}) still depends on {}",
251                        pool.display(r),
252                        pool.display(v),
253                        pool.display(w)
254                    )));
255                }
256            }
257            if contains(c, t, pool) {
258                return Err(DsolveSystemError::NonConstantCoefficient(format!(
259                    "∂({})/∂({}) = {} depends on {}",
260                    pool.display(r),
261                    pool.display(v),
262                    pool.display(c),
263                    pool.display(t)
264                )));
265            }
266            row.push(simp(c, pool));
267        }
268        // f = rhs − Σ A[i][j]·y_j
269        let mut acc = r;
270        for (c, &v) in row.iter().zip(ode.state_vars.iter()) {
271            acc = sub(acc, simp(pool.mul(vec![*c, v]), pool), pool);
272        }
273        for &w in &ode.state_vars {
274            if contains(acc, w, pool) {
275                return Err(DsolveSystemError::NotLinear(format!(
276                    "the state-independent remainder {} still depends on {}",
277                    pool.display(acc),
278                    pool.display(w)
279                )));
280            }
281        }
282        rows.push(row);
283        forcing.push(simp(acc, pool));
284    }
285    let a = Matrix::new(rows).map_err(|e| DsolveSystemError::NotLinear(e.to_string()))?;
286    Ok((a, forcing))
287}
288
289// ---------------------------------------------------------------------------
290// Spectrum
291// ---------------------------------------------------------------------------
292
293/// The `n` eigenvalues of `A`, listed with multiplicity.
294///
295/// A triangular `A` is answered from its diagonal directly.  That is not only a
296/// shortcut: for a compartment model — lower triangular by construction, and
297/// the case this module exists for — the general routine would build
298/// `det(λI − A)`, fail to clear it to ℤ\[λ\] because the entries are symbolic,
299/// and fall through to the Cardano formula, returning three nested-radical
300/// expressions for what the diagonal states in three symbols.
301fn spectrum(a: &Matrix, pool: &ExprPool) -> Result<Vec<ExprId>, DsolveSystemError> {
302    let n = a.rows;
303    if a.cols != n {
304        return Err(DsolveSystemError::NotLinear(
305            "coefficient matrix is not square".to_string(),
306        ));
307    }
308    if is_triangular(a, pool) {
309        return Ok((0..n).map(|i| simp(a.get(i, i), pool)).collect());
310    }
311    // A symbolic, non-triangular 3×3 has a cubic characteristic polynomial with
312    // free-symbol coefficients, and the only closed form for its roots is
313    // Cardano's.  Those radicals are correct only on a coordinated choice of
314    // three cube-root branches, they are not what a caller wants to read, and
315    // `simplify` grows rather than shrinks them: the resulting `e^{At}` is large
316    // enough that building it, let alone verifying it, does not terminate in any
317    // useful time.  Refusing here is cheaper and more honest than timing out and
318    // then declining anyway.
319    if n >= 3 && a.entries().iter().any(|&e| try_expr_f64(e, pool).is_none()) {
320        return Err(DsolveSystemError::UnsupportedSpectrum(format!(
321            "a {n}×{n} coefficient matrix with symbolic entries that is not triangular \
322             needs Cardano (or higher) radicals for its spectrum; those do not simplify, \
323             do not verify, and are not returned.  Substitute concrete values for the \
324             symbolic entries, or write the system in triangular (compartment) form"
325        )));
326    }
327    let eigs = crate::matrix::eigenvalues(a, pool)
328        .map_err(|e| DsolveSystemError::UnsupportedSpectrum(e.to_string()))?;
329    let mut out = Vec::with_capacity(n);
330    for (lam, mult) in eigs {
331        for _ in 0..mult {
332            out.push(simp(lam, pool));
333        }
334    }
335    if out.len() != n {
336        return Err(DsolveSystemError::UnsupportedSpectrum(format!(
337            "eigenvalue multiplicities sum to {} for an {n}×{n} matrix; the \
338             characteristic polynomial did not split",
339            out.len()
340        )));
341    }
342    Ok(out)
343}
344
345/// Cross-check the eigenvalue list before anything is built on it.
346///
347/// Delegates to `matrix::spectrum::confirm_spectrum`, which is the
348/// canonical form of this check and lives beside the routine that produces the
349/// spectrum — one copy, checked by one set of tests, rather than a second that
350/// drifts.  What stays here is the *policy*.
351///
352/// Putzer's algorithm turns a wrong eigenvalue list into a page-long `e^{At}`
353/// candidate that the substitution gate then refuses — correctly, but slowly,
354/// and under an error saying "verification failed" when the truth is "these are
355/// not the eigenvalues".  So a system is solved only on a spectrum that was
356/// positively **confirmed**, and an unevaluable one is refused here.
357/// `matrix::eigenvalues` is deliberately laxer: it has no expansion to protect,
358/// and refusing an unevaluable spectrum there would discard every correct
359/// symbolic answer whose radicals the numeric evaluator happens not to model.
360fn confirm_spectrum(
361    a: &Matrix,
362    lambdas: &[ExprId],
363    pool: &ExprPool,
364) -> Result<(), DsolveSystemError> {
365    match crate::matrix::spectrum::confirm_spectrum(a, lambdas, pool) {
366        Ok(check) if check.is_confirmed() => Ok(()),
367        Ok(_) => Err(DsolveSystemError::UnsupportedSpectrum(
368            "the eigenvalues of the coefficient matrix could not be evaluated at any \
369             sample, so nothing confirms they are its spectrum"
370                .to_string(),
371        )),
372        Err(refusal) => Err(DsolveSystemError::UnsupportedSpectrum(refusal.to_string())),
373    }
374}
375
376fn is_triangular(a: &Matrix, pool: &ExprPool) -> bool {
377    let n = a.rows;
378    let upper =
379        (0..n).all(|i| (0..i).all(|j| matches!(zero_status(pool, a.get(i, j)), ZeroStatus::Zero)));
380    let lower = (0..n)
381        .all(|i| ((i + 1)..n).all(|j| matches!(zero_status(pool, a.get(i, j)), ZeroStatus::Zero)));
382    upper || lower
383}
384
385// ---------------------------------------------------------------------------
386// Putzer
387// ---------------------------------------------------------------------------
388
389/// This module's policy for the one undecidable question Putzer asks.
390///
391/// A [`SystemSolution`] has a `side_conditions` field, so an undecided
392/// `λ_i − λ_j` is a *hypothesis carried with the answer*: the two-compartment
393/// model's `ka ≠ ke` is exactly this, together with a note naming the confluent
394/// limit.  [`crate::matrix::matrix_exponential`] divides by the same gaps but
395/// returns a bare `Matrix`, so it reports them out of band through
396/// [`putzer::RecordedGaps`] instead — same algorithm, different place to put
397/// the hypothesis, which is why the policy is a parameter.
398struct DsolveGaps<'a, 'b> {
399    ctx: &'a mut SolveCtx<'b>,
400}
401
402impl GapPolicy for DsolveGaps<'_, '_> {
403    fn classify(&mut self, lambda: ExprId, mu: ExprId, pool: &ExprPool) -> Gap {
404        let d = putzer::normalised_gap(lambda, mu, pool);
405        if lambda == mu || matches!(zero_status(pool, d), ZeroStatus::Zero) {
406            return Gap::Confluent;
407        }
408        require_nonzero(d, self.ctx, pool);
409        Gap::Distinct(d)
410    }
411}
412
413/// `e^{At}` by Putzer's algorithm, row-major.
414///
415/// The expansion itself lives in [`crate::matrix::putzer`], shared with
416/// [`crate::matrix::matrix_exponential`]: one `e^{At}` in the crate rather than
417/// two, after the other one was found to be wrong for every defective matrix.
418fn matrix_exponential(
419    a: &Matrix,
420    lambdas: &[ExprId],
421    t: ExprId,
422    ctx: &mut SolveCtx<'_>,
423    pool: &ExprPool,
424) -> Vec<Vec<ExprId>> {
425    let n = a.rows;
426    let phi = putzer::matrix_exponential(a, lambdas, t, &mut DsolveGaps { ctx }, pool)
427        .expect("a system with no state variables is rejected before this point");
428    (0..n)
429        .map(|i| (0..n).map(|j| phi.get(i, j)).collect())
430        .collect()
431}
432
433// ---------------------------------------------------------------------------
434// Assembly
435// ---------------------------------------------------------------------------
436
437fn solve_inner(
438    ode: &ODE,
439    ctx: &mut SolveCtx<'_>,
440    pool: &ExprPool,
441) -> Result<SystemSolution, DsolveSystemError> {
442    let n = ode.state_vars.len();
443    if n == 0 {
444        return Err(DsolveSystemError::NotLinear(
445            "the system has no state variables".to_string(),
446        ));
447    }
448    if ode.rhs.len() != n {
449        return Err(DsolveSystemError::NotLinear(
450            "state variable and right-hand-side counts differ".to_string(),
451        ));
452    }
453    let t = ode.time_var;
454    let (a, forcing) = extract_linear_system(ode, pool)?;
455    let lambdas = spectrum(&a, pool)?;
456    confirm_spectrum(&a, &lambdas, pool)?;
457    let phi = matrix_exponential(&a, &lambdas, t, ctx, pool);
458
459    // Homogeneous part: y = Φ(t)·C.
460    let mut used: HashSet<String> = HashSet::new();
461    for &e in ode.rhs.iter().chain(ode.state_vars.iter()) {
462        collect_symbol_names(e, pool, &mut used);
463    }
464    let mut gen = super::ConstGen::with_used(used);
465    let constants: Vec<ExprId> = (0..n).map(|_| gen.fresh(pool)).collect();
466    let mut y: Vec<ExprId> = (0..n)
467        .map(|i| {
468            let terms: Vec<ExprId> = (0..n)
469                .map(|j| pool.mul(vec![phi[i][j], constants[j]]))
470                .collect();
471            simp(pool.add(terms), pool)
472        })
473        .collect();
474
475    // Forcing: y_p = Φ(t)·∫ Φ(t)⁻¹ f(t) dt, using Φ(t)⁻¹ = Φ(−t).
476    let forced = forcing.iter().any(|&f| !is_zero(f, pool));
477    let method = if forced {
478        let phi_inv = negate_time(&phi, t, pool);
479        let mut integrated = Vec::with_capacity(n);
480        for row in &phi_inv {
481            let integrand = simp(
482                pool.add(
483                    row.iter()
484                        .zip(forcing.iter())
485                        .map(|(&p, &f)| pool.mul(vec![p, f]))
486                        .collect(),
487                ),
488                pool,
489            );
490            let g = integrate_or_decline(integrand, t, pool)
491                .map_err(|e| DsolveSystemError::Unsupported(e.to_string()))?;
492            integrated.push(g);
493        }
494        for (i, yi) in y.iter_mut().enumerate() {
495            let terms: Vec<ExprId> = (0..n)
496                .map(|j| pool.mul(vec![phi[i][j], integrated[j]]))
497                .collect();
498            *yi = simp(pool.add(vec![*yi, pool.add(terms)]), pool);
499        }
500        "linear_system_putzer_variation_of_parameters"
501    } else {
502        "linear_system_putzer"
503    };
504
505    verify_system(ode, &y, &constants, pool)?;
506
507    Ok(SystemSolution {
508        state_vars: ode.state_vars.clone(),
509        y_of_t: y,
510        constants,
511        fundamental_matrix: phi,
512        method,
513        side_conditions: ctx.conds.side.clone(),
514        notes: ctx.conds.notes.clone(),
515    })
516}
517
518/// `Φ(−t)`: substitute `t → −t` in every entry.
519fn negate_time(phi: &[Vec<ExprId>], t: ExprId, pool: &ExprPool) -> Vec<Vec<ExprId>> {
520    let neg_t = pool.mul(vec![pool.integer(-1_i32), t]);
521    let mut m = HashMap::new();
522    m.insert(t, neg_t);
523    phi.iter()
524        .map(|row| {
525            row.iter()
526                .map(|&e| simp(crate::kernel::subs::subs(e, &m, pool), pool))
527                .collect()
528        })
529        .collect()
530}
531
532fn is_zero(e: ExprId, pool: &ExprPool) -> bool {
533    matches!(zero_status(pool, e), ZeroStatus::Zero)
534}
535
536fn collect_symbol_names(expr: ExprId, pool: &ExprPool, out: &mut HashSet<String>) {
537    pool.with(expr, |d| match d {
538        ExprData::Symbol { name, .. } => {
539            out.insert(name.clone());
540        }
541        ExprData::Add(args) | ExprData::Mul(args) | ExprData::Func { args, .. } => {
542            for &a in args {
543                collect_symbol_names(a, pool, out);
544            }
545        }
546        ExprData::Pow { base, exp } => {
547            collect_symbol_names(*base, pool, out);
548            collect_symbol_names(*exp, pool, out);
549        }
550        _ => {}
551    });
552}
553
554/// Record `e ≠ 0` and the confluence it excludes, unless already settled.
555fn require_nonzero(e: ExprId, ctx: &mut SolveCtx<'_>, pool: &ExprPool) {
556    if super::is_settled_nonzero(e, pool) || ctx.assumed_sign(e, pool) != AssumedSign::Unknown {
557        return;
558    }
559    ctx.conds.require_nonzero(e);
560    ctx.conds.note(format!(
561        "two eigenvalues of the coefficient matrix differ by {}; where that vanishes \
562         the returned expression divides by zero.  Its limit there exists and is the \
563         confluent (t·e^{{λt}}) form, but this solver does not take it — pass concrete \
564         values, or assert the difference is non-zero, to get an answer valid at that \
565         point",
566        pool.display(e)
567    ));
568}
569
570// ---------------------------------------------------------------------------
571// Verification
572// ---------------------------------------------------------------------------
573
574/// Absolute floor of the "this residual is zero" band.
575const ZERO_TOL: f64 = 1e-9;
576/// Relative part of the band; see [`super::verify`] on why it is relative.
577const REL_TOL: f64 = 1e-7;
578/// Resolved, agreeing `(sample, equation)` pairs required for a numeric
579/// certificate.
580const MIN_AGREEING: usize = 12;
581
582/// What one `(parameters, constants, t)` sample of the candidate produced.
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584enum SampleOutcome {
585    /// Every component and every derivative is a finite complex number.
586    Resolved,
587    /// Every component evaluated, and at least one is not finite.
588    CandidateNonFinite,
589    /// Some component contains a construct the evaluator does not know.
590    Unevaluable,
591}
592
593/// Is the system's own right-hand side finite at this `t` and these parameters,
594/// candidate aside?  Probes several finite states, as the scalar gate's
595/// `ode_is_regular_at` does; one finite probe is enough, since the question is
596/// whether the *equations* have a singularity here.
597fn system_is_regular_at(ode: &ODE, env: &HashMap<ExprId, C64>, pool: &ExprPool) -> bool {
598    const PROBES: [f64; 3] = [1.0, 2.5, -1.5];
599    PROBES.iter().any(|&p| {
600        let mut e = env.clone();
601        for (k, &v) in ode.state_vars.iter().enumerate() {
602            e.insert(v, C64::real(p + 0.25 * k as f64));
603        }
604        ode.rhs
605            .iter()
606            .all(|&r| matches!(eval_complex(r, &e, pool), Some(v) if v.is_finite()))
607    })
608}
609
610/// Substitute the candidate into **every** equation and require zero.
611fn verify_system(
612    ode: &ODE,
613    y: &[ExprId],
614    constants: &[ExprId],
615    pool: &ExprPool,
616) -> Result<(), DsolveSystemError> {
617    let t = ode.time_var;
618    let mut subst: HashMap<ExprId, ExprId> = HashMap::new();
619    for (&v, &sol) in ode.state_vars.iter().zip(y.iter()) {
620        subst.insert(v, sol);
621    }
622    let mut residuals = Vec::with_capacity(y.len());
623    let mut derivs = Vec::with_capacity(y.len());
624    for (i, &sol) in y.iter().enumerate() {
625        let dy = ddx(sol, t, pool).map_err(|e| DsolveSystemError::Unsupported(e.to_string()))?;
626        derivs.push(dy);
627        let rhs = simp(crate::kernel::subs::subs(ode.rhs[i], &subst, pool), pool);
628        residuals.push(simp(sub(dy, rhs, pool), pool));
629    }
630    if residuals
631        .iter()
632        .all(|&r| is_literal_zero(r, pool) || is_literal_zero(simp_plain(r, pool), pool))
633    {
634        return Ok(());
635    }
636
637    // Numeric fallback over ℂ: `t`, the constants, and any free parameters.
638    let mut bound: Vec<ExprId> = vec![t];
639    bound.extend_from_slice(&ode.state_vars);
640    bound.extend_from_slice(constants);
641    // `pi` and the imaginary unit are symbols in this crate but they are not
642    // parameters: `collect_free_symbols` leaves them out and `eval_complex`
643    // gives them their own values.  Sampling `pi` at 1.7 is what refused the
644    // casus-irreducibilis companion matrix, whose eigenvalues are the correct
645    // `2√(−p/3)·cos((acos c + 2πk)/3)`.
646    let mut params: Vec<ExprId> = Vec::new();
647    for &r in &residuals {
648        collect_free_symbols(r, pool, &mut params);
649    }
650    params.retain(|s| !bound.contains(s));
651    params.sort_by_key(|&s| pool.display(s).to_string());
652
653    // The scalar gate's tables, not a second copy of them: a parameter must be
654    // sampled the same way whichever gate is asking, and the sign coverage of
655    // `PARAM_SETS` is a soundness property that has to hold in both.
656    let param_sets = PARAM_SETS;
657    let const_sets = CONST_SETS;
658    let t_samples = X_SAMPLES;
659
660    let (mut agree, mut disagree, mut skipped) = (0usize, 0usize, 0usize);
661    let mut sets_agreeing = 0usize;
662    for ps in param_sets {
663        let mut env: HashMap<ExprId, C64> = HashMap::new();
664        for (i, &p) in params.iter().enumerate() {
665            env.insert(p, C64::real(ps[i % ps.len()]));
666        }
667        let before = agree;
668        for cs in const_sets {
669            for (i, &c) in constants.iter().enumerate() {
670                env.insert(c, C64::real(cs[i % cs.len()]));
671            }
672            for &tv in &t_samples {
673                env.insert(t, C64::real(tv));
674                // Split evaluation: the candidate on one side, the system's own
675                // right-hand side on the other, so a non-finite sample can be
676                // attributed rather than guessed at.
677                //
678                // The two failures are *not* symmetric, and treating them alike
679                // is the mistake `integrate::gate` documents.  A construct the
680                // evaluator does not know is a property of the expression, not
681                // of the point, and carries no information.  A value it does
682                // know and that comes back non-finite where the system itself is
683                // perfectly regular is evidence the candidate is wrong — a
684                // blanket skip there is how a domain hole clears a grid built to
685                // catch one.
686                let mut yv = Vec::with_capacity(y.len());
687                let mut dv = Vec::with_capacity(y.len());
688                let mut outcome = SampleOutcome::Resolved;
689                for i in 0..y.len() {
690                    match (
691                        eval_complex(y[i], &env, pool),
692                        eval_complex(derivs[i], &env, pool),
693                    ) {
694                        (Some(a), Some(b)) if a.is_finite() && b.is_finite() => {
695                            yv.push(a);
696                            dv.push(b);
697                        }
698                        (Some(_), Some(_)) => {
699                            outcome = SampleOutcome::CandidateNonFinite;
700                            break;
701                        }
702                        _ => {
703                            outcome = SampleOutcome::Unevaluable;
704                            break;
705                        }
706                    }
707                }
708                match outcome {
709                    SampleOutcome::Resolved => {}
710                    SampleOutcome::Unevaluable => {
711                        skipped += 1;
712                        continue;
713                    }
714                    SampleOutcome::CandidateNonFinite => {
715                        // Is the *system* regular here, candidate aside?  A
716                        // constant-coefficient right-hand side is finite at any
717                        // finite state unless the forcing term has a pole at
718                        // this `t`, which is the one case that carries no
719                        // information.
720                        if system_is_regular_at(ode, &env, pool) {
721                            disagree += 1;
722                        } else {
723                            skipped += 1;
724                        }
725                        continue;
726                    }
727                }
728                let mut rhs_env = env.clone();
729                for (k, &v) in ode.state_vars.iter().enumerate() {
730                    rhs_env.insert(v, yv[k]);
731                }
732                let scale = yv
733                    .iter()
734                    .chain(dv.iter())
735                    .fold(1.0_f64, |m, v| m.max(v.abs()));
736                for i in 0..y.len() {
737                    match eval_complex(ode.rhs[i], &rhs_env, pool) {
738                        Some(v) if v.is_finite() => {
739                            let resid = dv[i].sub(v).abs();
740                            if resid <= ZERO_TOL.max(REL_TOL * scale) {
741                                agree += 1;
742                            } else {
743                                disagree += 1;
744                            }
745                        }
746                        // The system itself is not finite at this state — no
747                        // information about the candidate.
748                        _ => skipped += 1,
749                    }
750                }
751            }
752        }
753        if agree > before {
754            sets_agreeing += 1;
755        }
756    }
757    if disagree == 0 && agree >= MIN_AGREEING && sets_agreeing >= 2 {
758        return Ok(());
759    }
760    let mut shown = pool.display(residuals[0]).to_string();
761    // A refused candidate can be pages long; the message is a diagnostic, not
762    // the expression itself.
763    if shown.len() > 400 {
764        shown.truncate(400);
765        shown.push_str(" …");
766    }
767    Err(DsolveSystemError::VerificationFailed(format!(
768        "samples: {agree} agreeing, {disagree} disagreeing, {skipped} unresolved \
769         ({sets_agreeing} parameter sets agreeing); first residual: {shown}"
770    )))
771}
772
773fn is_literal_zero(e: ExprId, pool: &ExprPool) -> bool {
774    matches!(pool.get(e), ExprData::Integer(n) if n.0 == 0)
775}
776
777#[cfg(test)]
778mod tests;