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;