Skip to main content

symplex/api/
expr_solve_ext.rs

1//! Extended solving methods on [`Ex`]: general solutions, systems, recurrences, IVPs.
2//!
3//! This module hosts the 0.2 solving additions:
4//!
5//! - [`Ex::solve_general`] — full periodic solution families for trig
6//!   equations, with a fresh integer parameter.
7//! - [`linsolve`] / [`linsolve_matrix`] — symbolic linear systems
8//!   (unique, parametric, or inconsistent) via reduced row-echelon form.
9//! - [`solve_numeric_system`] — damped Newton iteration for square
10//!   nonlinear systems with a symbolic Jacobian.
11//! - [`Ex::solve_ode_ivp`] — initial-value problems on top of the
12//!   general ODE solver.
13
14use crate::api::context::Context;
15use crate::api::eq::Equation;
16use crate::api::expr::Ex;
17use crate::base::assumptions::Assumption;
18use crate::base::dense_f64;
19use crate::base::errors::SymplexError;
20use crate::base::node::SymbolId;
21use crate::domains::matrix::Matrix;
22
23// ═══════════════════════════════════════════════════════════════════════════
24// Zero-form conversion
25// ═══════════════════════════════════════════════════════════════════════════
26
27/// Types that can be viewed as an equation `expr = 0`.
28///
29/// Implemented for [`Ex`] (the expression itself is the zero form) and
30/// [`Equation`] (`lhs - rhs`), so solver entry points accept either.
31///
32/// ```
33/// use symplex::prelude::*;
34/// use symplex::polysys::ZeroForm;
35///
36/// let ctx = Context::new();
37/// let x = ctx.symbol("x");
38/// let eq = Equation::new(&x + 1, ctx.int(3));
39/// assert_eq!(format!("{}", eq.to_zero_form()), "x - 2");
40/// ```
41pub trait ZeroForm {
42    /// Return the expression that equals zero when the equation holds.
43    fn to_zero_form(&self) -> Ex;
44}
45
46impl ZeroForm for Ex {
47    fn to_zero_form(&self) -> Ex {
48        self.clone()
49    }
50}
51
52impl ZeroForm for Equation {
53    fn to_zero_form(&self) -> Ex {
54        self.to_expr()
55    }
56}
57
58impl<T: ZeroForm> ZeroForm for &T {
59    fn to_zero_form(&self) -> Ex {
60        (*self).to_zero_form()
61    }
62}
63
64// ═══════════════════════════════════════════════════════════════════════════
65// Linear systems
66// ═══════════════════════════════════════════════════════════════════════════
67
68/// Result of solving a linear system with [`linsolve`].
69///
70/// Values are always given in the order of the `vars` slice passed to the
71/// solver.
72#[derive(Debug, Clone)]
73pub enum LinearSolution {
74    /// Exactly one solution: `(variable, value)` pairs.
75    Unique(Vec<(Ex, Ex)>),
76    /// Infinitely many solutions.  `solution` gives every variable — pivot
77    /// variables are expressed in terms of the `free` variables, and each
78    /// free variable maps to itself.
79    Parametric {
80        /// `(variable, value)` pairs for every unknown.
81        solution: Vec<(Ex, Ex)>,
82        /// The free (parameter) variables, a subset of the unknowns.
83        free: Vec<Ex>,
84    },
85    /// No solution: elimination produced a row `0 = c` with `c ≠ 0`.
86    ///
87    /// This is reported as a variant rather than an `Err` because it is a
88    /// legitimate mathematical outcome; `Err` is reserved for malformed
89    /// input (non-linear equations, empty input, shape mismatch).
90    Inconsistent,
91}
92
93impl LinearSolution {
94    /// `true` for [`LinearSolution::Unique`].
95    #[must_use]
96    #[allow(dead_code)] // public API; reachable once re-exported from lib.rs
97    pub fn is_unique(&self) -> bool {
98        matches!(self, LinearSolution::Unique(_))
99    }
100
101    /// `true` for [`LinearSolution::Inconsistent`].
102    #[must_use]
103    #[allow(dead_code)] // public API; reachable once re-exported from lib.rs
104    pub fn is_inconsistent(&self) -> bool {
105        matches!(self, LinearSolution::Inconsistent)
106    }
107
108    /// The `(variable, value)` pairs, or `None` if inconsistent.
109    #[must_use]
110    #[allow(dead_code)] // public API; reachable once re-exported from lib.rs
111    pub fn pairs(&self) -> Option<&[(Ex, Ex)]> {
112        match self {
113            LinearSolution::Unique(p) => Some(p),
114            LinearSolution::Parametric { solution, .. } => Some(solution),
115            LinearSolution::Inconsistent => None,
116        }
117    }
118
119    /// Look up the value of a particular variable.
120    #[must_use]
121    #[allow(dead_code)] // public API; reachable once re-exported from lib.rs
122    pub fn get(&self, var: &Ex) -> Option<Ex> {
123        self.pairs()?
124            .iter()
125            .find(|(v, _)| v == var)
126            .map(|(_, val)| val.clone())
127    }
128}
129
130/// Solve a system of linear equations symbolically.
131///
132/// Each element of `eqs` is either an [`Ex`] (meaning `expr = 0`) or an
133/// [`Equation`].  Coefficients may be symbolic; the solver performs
134/// reduced row-echelon elimination, preferring numeric pivots and
135/// treating a symbolic pivot as nonzero (generic solution).
136///
137/// Under-determined systems return [`LinearSolution::Parametric`],
138/// over-determined but consistent systems return
139/// [`LinearSolution::Unique`], and contradictory systems return
140/// [`LinearSolution::Inconsistent`].
141///
142/// # Errors
143///
144/// [`SymplexError::InvalidArgument`] if `eqs` or `vars` is empty, or an
145/// equation is not linear in the unknowns.
146///
147/// # Examples
148///
149/// ```
150/// use symplex::prelude::*;
151/// use symplex::polysys::linsolve;
152///
153/// let ctx = Context::new();
154/// let (x, y, a, b) = (ctx.symbol("x"), ctx.symbol("y"), ctx.symbol("a"), ctx.symbol("b"));
155/// // a*x + y = 1,  x - y = b
156/// let sol = linsolve(&[&a * &x + &y - 1, &x - &y - &b], &[x.clone(), y.clone()]).unwrap();
157/// let xv = sol.get(&x).unwrap();
158/// // x = (1 + b) / (a + 1)
159/// let residual = (&xv * (&a + 1) - (&b + 1)).simplify();
160/// assert!(residual.is_zero_structural(), "x = {xv}");
161///
162/// // Under-determined: x + y = 1
163/// let sol = linsolve(&[&x + &y - 1], &[x.clone(), y.clone()]).unwrap();
164/// assert!(matches!(sol, symplex::polysys::LinearSolution::Parametric { .. }));
165///
166/// // Over-determined but consistent (three equations, two unknowns): `Unique`,
167/// // not an error.  A contradictory third equation gives `Inconsistent`.
168/// use symplex::polysys::LinearSolution;
169/// let sol = linsolve(&[&x - 1, &y - 2, &x + &y - 3], &[x.clone(), y.clone()]).unwrap();
170/// assert!(matches!(sol, LinearSolution::Unique(_)));
171/// let sol = linsolve(&[&x - 1, &y - 2, &x + &y - 4], &[x.clone(), y.clone()]).unwrap();
172/// assert!(matches!(sol, LinearSolution::Inconsistent));
173/// ```
174pub fn linsolve<E: ZeroForm>(eqs: &[E], vars: &[Ex]) -> Result<LinearSolution, SymplexError> {
175    let zero_forms: Vec<Ex> = eqs.iter().map(ZeroForm::to_zero_form).collect();
176    let result = crate::domains::linalg::linsolve_symbolic(&zero_forms, vars)?;
177    Ok(wrap_linear_result(result, vars))
178}
179
180/// Convert the backend result into the public enum.
181fn wrap_linear_result(
182    result: crate::domains::linalg::SymbolicLinearResult,
183    vars: &[Ex],
184) -> LinearSolution {
185    if result.inconsistent {
186        return LinearSolution::Inconsistent;
187    }
188    let solution: Vec<(Ex, Ex)> = vars
189        .iter()
190        .cloned()
191        .zip(result.values.iter().cloned())
192        .collect();
193    if result.free.is_empty() {
194        LinearSolution::Unique(solution)
195    } else {
196        let free = result.free.iter().map(|&i| vars[i].clone()).collect();
197        LinearSolution::Parametric { solution, free }
198    }
199}
200
201/// Solve `A·x = b` for a coefficient matrix `a` (m×n) and right-hand side
202/// `b` (m×1), with symbolic entries allowed.
203///
204/// The unknowns are named `x1, …, xn` in the context of `a`.  Unlike
205/// [`Matrix::solve`], this handles rectangular, singular and inconsistent
206/// systems, reporting free variables where appropriate.
207///
208/// # Errors
209///
210/// [`SymplexError::InvalidArgument`] if `b` is not a column vector with
211/// as many rows as `a`.
212///
213/// # Examples
214///
215/// ```
216/// use symplex::prelude::*;
217/// use symplex::polysys::{linsolve_matrix, LinearSolution};
218///
219/// let ctx = Context::new();
220/// let a = matrix![ctx, [1, 1], [1, -1]];
221/// let b = Matrix::col_vector(vec![ctx.int(3), ctx.int(1)]);
222/// match linsolve_matrix(&a, &b).unwrap() {
223///     LinearSolution::Unique(pairs) => {
224///         assert_eq!(format!("{}", pairs[0].1), "2");
225///         assert_eq!(format!("{}", pairs[1].1), "1");
226///     }
227///     other => panic!("expected unique solution, got {other:?}"),
228/// }
229/// ```
230#[allow(dead_code)] // public API; reachable once re-exported from lib.rs
231pub fn linsolve_matrix(a: &Matrix, b: &Matrix) -> Result<LinearSolution, SymplexError> {
232    let m = a.nrows();
233    let n = a.ncols();
234    if b.ncols() != 1 || b.nrows() != m {
235        return Err(SymplexError::InvalidArgument {
236            operation: "linsolve_matrix",
237            reason: format!(
238                "right-hand side must be {m}×1, got {}×{}",
239                b.nrows(),
240                b.ncols()
241            ),
242        });
243    }
244    let ctx = a.get(0, 0).context();
245    let unknowns: Vec<Ex> = (1..=n).map(|i| ctx.symbol(&format!("x{i}"))).collect();
246    let rows: Vec<Vec<Ex>> = (0..m)
247        .map(|i| (0..n).map(|j| a.get(i, j).clone()).collect())
248        .collect();
249    let rhs: Vec<Ex> = (0..m).map(|i| b.get(i, 0).clone()).collect();
250    let result = crate::domains::linalg::rref_solve(rows, rhs, &unknowns)?;
251    Ok(wrap_linear_result(result, &unknowns))
252}
253
254// ═══════════════════════════════════════════════════════════════════════════
255// General (periodic) solutions
256// ═══════════════════════════════════════════════════════════════════════════
257
258/// Result of [`Ex::solve_general`]: solution families plus the integer
259/// parameters they are expressed with.
260#[derive(Debug, Clone)]
261pub struct GeneralSolution {
262    /// Solution expressions.  Families of periodic solutions mention the
263    /// symbols in `parameters`; non-periodic solutions do not.
264    pub solutions: Vec<Ex>,
265    /// Fresh integer-assumed parameter symbols (`n`, `n1`, …) that appear
266    /// in `solutions`.  Empty when no periodic family was produced.
267    pub parameters: Vec<Ex>,
268}
269
270impl GeneralSolution {
271    /// Substitute a concrete integer for every parameter, giving one
272    /// representative of each family.
273    #[must_use]
274    pub fn instance(&self, k: i64) -> Vec<Ex> {
275        self.solutions
276            .iter()
277            .map(|s| {
278                let mut e = s.clone();
279                for p in &self.parameters {
280                    e = e.subs_i64(p, k);
281                }
282                e.eval()
283            })
284            .collect()
285    }
286}
287
288/// Create a symbol whose name is not yet used anywhere in the context.
289///
290/// Tries `base`, then `base1`, `base2`, … and attaches the given
291/// assumptions.
292pub(crate) fn fresh_symbol(ctx: &Context, base: &str, assumptions: &[Assumption]) -> Ex {
293    let name = {
294        let inner = ctx.inner.read();
295        let taken = |name: &str| -> bool {
296            let n = inner.arena.symbols.len();
297            (0..n).any(|i| inner.arena.symbols.name(SymbolId(i as u32)) == name)
298        };
299        if !taken(base) {
300            base.to_string()
301        } else {
302            let mut k = 1usize;
303            loop {
304                let candidate = format!("{base}{k}");
305                if !taken(&candidate) {
306                    break candidate;
307                }
308                k += 1;
309            }
310        }
311    };
312    ctx.symbol_with(&name, assumptions)
313}
314
315impl Ex {
316    /// Solve `self = 0` for `var`, returning **general** solution families.
317    ///
318    /// Unlike [`solve`](Ex::solve), which returns only principal branches,
319    /// this expresses periodic solutions with a fresh integer parameter
320    /// (`n`, or `n1`, `n2`, … if `n` is already in use), exposed through
321    /// [`GeneralSolution::parameters`]:
322    ///
323    /// - `sin(x) = c` → `asin(c) + 2πn`, `π − asin(c) + 2πn`
324    /// - `cos(x) = c` → `±acos(c) + 2πn`
325    /// - `tan(x) = c` → `atan(c) + πn`
326    ///
327    /// Linear arguments (`sin(a·x + b) = c`) and change-of-variable forms
328    /// (`sin²x − sin x = 0`) are supported.  Non-periodic equations return
329    /// the same solutions as `solve` with an empty parameter list.
330    ///
331    /// # Errors
332    ///
333    /// Same as [`solve`](Ex::solve): `InfiniteSolutions` for identities,
334    /// `NoSolution` for contradictions, `ComputationFailed` when nothing
335    /// applies.
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use symplex::prelude::*;
341    ///
342    /// let ctx = Context::new();
343    /// let x = ctx.symbol("x");
344    /// let eq = &x.sin() - &ctx.rational(1, 2);
345    /// let fam = eq.solve_general(&x).unwrap();
346    /// assert_eq!(fam.solutions.len(), 2);
347    /// assert_eq!(fam.parameters.len(), 1);
348    /// // Every member of every family satisfies the equation.
349    /// for k in -2..=2 {
350    ///     for s in fam.instance(k) {
351    ///         let residual = eq.subs(&x, &s).eval_f64().unwrap();
352    ///         assert!(residual.abs() < 1e-12);
353    ///     }
354    /// }
355    /// ```
356    pub fn solve_general(&self, var: &Ex) -> Result<GeneralSolution, SymplexError> {
357        let var_id = self.checked_id(var);
358        let ctx = self.context();
359        let param = fresh_symbol(&ctx, "n", &[Assumption::Integer]);
360        let param_id = self.checked_id(&param);
361
362        let outcome = {
363            let mut inner = self.inner.write();
364            crate::transforms::solve::solve_general(
365                &mut inner.arena,
366                self.raw_id(),
367                var_id,
368                param_id,
369            )
370        };
371        match outcome {
372            crate::transforms::solve::SolveOutcome::Solutions(solutions) => {
373                if solutions.is_empty() {
374                    let is_poly = {
375                        let inner = self.inner.read();
376                        crate::poly::polybridge::expr_to_poly(&inner.arena, self.raw_id(), var_id)
377                            .is_some()
378                    };
379                    if !is_poly {
380                        return Err(SymplexError::ComputationFailed {
381                            operation: "solve_general",
382                            reason: "expression is not polynomial in the given variable and transcendental solver could not find solutions".into(),
383                        });
384                    }
385                    return Ok(GeneralSolution {
386                        solutions: Vec::new(),
387                        parameters: Vec::new(),
388                    });
389                }
390                let solutions: Vec<Ex> = solutions
391                    .into_iter()
392                    .map(|s| self.wrap(s.value).eval())
393                    .collect();
394                let uses_param = solutions.iter().any(|s| s.contains(&param));
395                Ok(GeneralSolution {
396                    solutions,
397                    parameters: if uses_param { vec![param] } else { Vec::new() },
398                })
399            }
400            crate::transforms::solve::SolveOutcome::Identity => {
401                Err(SymplexError::InfiniteSolutions {
402                    operation: "solve_general",
403                    reason: format!(
404                        "equation is an identity (0 = 0): every value of {var} is a solution"
405                    ),
406                })
407            }
408            crate::transforms::solve::SolveOutcome::NoSolution(reason) => {
409                Err(SymplexError::NoSolution {
410                    operation: "solve_general",
411                    reason,
412                })
413            }
414        }
415    }
416}
417
418// ═══════════════════════════════════════════════════════════════════════════
419// Numeric multivariate solving (Newton)
420// ═══════════════════════════════════════════════════════════════════════════
421
422/// Options for [`solve_numeric_system_with`].
423#[derive(Debug, Clone, Copy, PartialEq)]
424#[allow(dead_code)] // public API; reachable once re-exported from lib.rs
425pub struct NewtonOpts {
426    /// Convergence threshold on the residual norm `‖F(x)‖∞`.
427    pub tol: f64,
428    /// Maximum number of Newton iterations.
429    pub max_iter: usize,
430    /// Enable backtracking line search (halve the step until the residual
431    /// decreases).  Disable for pure Newton steps.
432    pub damping: bool,
433}
434
435impl Default for NewtonOpts {
436    fn default() -> Self {
437        Self {
438            tol: 1e-12,
439            max_iter: 100,
440            damping: true,
441        }
442    }
443}
444
445/// Callable scalar function of `k` variables, compiled when possible and
446/// falling back to exact substitution + `eval_f64` otherwise.
447enum Evaluator {
448    Compiled(crate::output::lambdify::CompiledFn),
449    Symbolic(Ex, Vec<Ex>),
450}
451
452impl Evaluator {
453    fn new(expr: &Ex, vars: &[Ex], names: &[&str]) -> Self {
454        match expr.compile(names) {
455            Ok(f) => Evaluator::Compiled(f),
456            Err(_) => Evaluator::Symbolic(expr.clone(), vars.to_vec()),
457        }
458    }
459
460    fn call(&self, x: &[f64]) -> Result<f64, SymplexError> {
461        match self {
462            Evaluator::Compiled(f) => Ok(f.call(x)),
463            Evaluator::Symbolic(expr, vars) => {
464                let mut e = expr.clone();
465                for (v, &xv) in vars.iter().zip(x) {
466                    let val = float_to_ex(v, xv)?;
467                    e = e.subs(v, &val);
468                }
469                e.eval_f64()
470            }
471        }
472    }
473}
474
475/// Exact rational literal for an `f64` (dyadic), in the context of `like`.
476fn float_to_ex(like: &Ex, x: f64) -> Result<Ex, SymplexError> {
477    let r = num_rational::Ratio::<num_bigint::BigInt>::from_float(x).ok_or_else(|| {
478        SymplexError::ComputationFailed {
479            operation: "solve_numeric_system",
480            reason: format!("non-finite value {x} encountered"),
481        }
482    })?;
483    let mut inner = like.inner.write();
484    let nid = inner.arena.intern_num(r);
485    let id = inner.arena.intern(crate::base::node::ExprNode::Num(nid));
486    drop(inner);
487    Ok(like.wrap(id))
488}
489
490/// Solve the dense linear system `a·x = b` with partial pivoting
491/// ([`dense_f64::solve_partial_pivot`]).  Returns `None` if the matrix is
492/// numerically singular or has a non-finite entry.
493fn gauss_solve(a: Vec<Vec<f64>>, b: Vec<f64>) -> Option<Vec<f64>> {
494    let flat = dense_f64::flatten(&a);
495    if flat.iter().any(|v| !v.is_finite()) {
496        return None;
497    }
498    dense_f64::solve_partial_pivot(&flat, b.len(), &b)
499}
500
501fn inf_norm(v: &[f64]) -> f64 {
502    v.iter().fold(0.0_f64, |m, &x| m.max(x.abs()))
503}
504
505/// Newton's method for the square nonlinear system `eqs = 0` in `vars`,
506/// starting from `x0`, with default options (`tol = 1e-12`,
507/// `max_iter = 100`, damping on).
508///
509/// See [`solve_numeric_system_with`].
510///
511/// # Examples
512///
513/// ```
514/// use symplex::prelude::*;
515/// use symplex::polysys::solve_numeric_system;
516///
517/// let ctx = Context::new();
518/// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
519/// // Intersection of the unit circle with y = x, near (1, 1)
520/// let eqs = [&x.powi(2) + &y.powi(2) - 1, &y - &x];
521/// let sol = solve_numeric_system(&eqs, &[x, y], &[1.0, 1.0]).unwrap();
522/// let r = std::f64::consts::FRAC_1_SQRT_2;
523/// assert!((sol[0] - r).abs() < 1e-10 && (sol[1] - r).abs() < 1e-10);
524/// ```
525#[allow(dead_code)] // public API; reachable once re-exported from lib.rs
526pub fn solve_numeric_system(eqs: &[Ex], vars: &[Ex], x0: &[f64]) -> Result<Vec<f64>, SymplexError> {
527    solve_numeric_system_with(eqs, vars, x0, &NewtonOpts::default())
528}
529
530/// Newton's method with explicit [`NewtonOpts`].
531///
532/// The Jacobian is computed symbolically ([`crate::matrix::jacobian`]) and
533/// compiled to native closures where possible.  Each step solves
534/// `J·Δ = −F` by Gaussian elimination with partial pivoting; with
535/// `damping` on, the step is halved until the residual norm decreases.
536///
537/// # Errors
538///
539/// - [`SymplexError::InvalidArgument`] if the system is not square or
540///   `x0` has the wrong length.
541/// - [`SymplexError::ComputationFailed`] if the Jacobian becomes singular
542///   or the iteration does not converge within `max_iter` steps; the
543///   message reports the final residual norm and iterate.
544#[allow(dead_code)] // public API; reachable once re-exported from lib.rs
545pub fn solve_numeric_system_with(
546    eqs: &[Ex],
547    vars: &[Ex],
548    x0: &[f64],
549    opts: &NewtonOpts,
550) -> Result<Vec<f64>, SymplexError> {
551    let n = vars.len();
552    if n == 0 || eqs.len() != n {
553        return Err(SymplexError::InvalidArgument {
554            operation: "solve_numeric_system",
555            reason: format!(
556                "system must be square and non-empty: {} equations, {} unknowns",
557                eqs.len(),
558                n
559            ),
560        });
561    }
562    if x0.len() != n {
563        return Err(SymplexError::InvalidArgument {
564            operation: "solve_numeric_system",
565            reason: format!("initial guess has {} entries, expected {n}", x0.len()),
566        });
567    }
568    for v in vars {
569        let _ = eqs[0].checked_id(v);
570    }
571    for e in eqs {
572        let _ = eqs[0].checked_id(e);
573    }
574
575    let names: Vec<String> = vars.iter().map(|v| format!("{v}")).collect();
576    let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
577
578    let f_refs: Vec<&Ex> = eqs.iter().collect();
579    let v_refs: Vec<&Ex> = vars.iter().collect();
580    let jac = crate::domains::matrix::jacobian(&f_refs, &v_refs);
581
582    let f_eval: Vec<Evaluator> = eqs
583        .iter()
584        .map(|e| Evaluator::new(e, vars, &name_refs))
585        .collect();
586    let j_eval: Vec<Vec<Evaluator>> = (0..n)
587        .map(|i| {
588            (0..n)
589                .map(|j| Evaluator::new(jac.get(i, j), vars, &name_refs))
590                .collect()
591        })
592        .collect();
593
594    let residual = |x: &[f64]| -> Result<Vec<f64>, SymplexError> {
595        f_eval.iter().map(|f| f.call(x)).collect()
596    };
597
598    let mut x = x0.to_vec();
599    let mut fx = residual(&x)?;
600    let mut norm = inf_norm(&fx);
601
602    for _iter in 0..opts.max_iter {
603        if norm < opts.tol {
604            return Ok(x);
605        }
606        if !norm.is_finite() {
607            break;
608        }
609        let mut jm = vec![vec![0.0; n]; n];
610        for i in 0..n {
611            for j in 0..n {
612                jm[i][j] = j_eval[i][j].call(&x)?;
613            }
614        }
615        let neg_f: Vec<f64> = fx.iter().map(|v| -v).collect();
616        let dx = match gauss_solve(jm, neg_f) {
617            Some(d) => d,
618            None => {
619                return Err(SymplexError::ComputationFailed {
620                    operation: "solve_numeric_system",
621                    reason: format!("Jacobian is singular at x = {x:?} (residual norm {norm:.3e})"),
622                });
623            }
624        };
625
626        // Backtracking line search.
627        let mut step = 1.0;
628        loop {
629            let x_new: Vec<f64> = x.iter().zip(&dx).map(|(a, d)| a + step * d).collect();
630            let f_new = residual(&x_new)?;
631            let norm_new = inf_norm(&f_new);
632            if !opts.damping || norm_new < norm || step < 1e-10 {
633                x = x_new;
634                fx = f_new;
635                norm = norm_new;
636                break;
637            }
638            step *= 0.5;
639        }
640    }
641
642    if norm < opts.tol {
643        return Ok(x);
644    }
645    Err(SymplexError::ComputationFailed {
646        operation: "solve_numeric_system",
647        reason: format!(
648            "did not converge within {} iterations: residual norm {norm:.3e} at x = {x:?}",
649            opts.max_iter
650        ),
651    })
652}
653
654// ═══════════════════════════════════════════════════════════════════════════
655// ODE initial-value problems
656// ═══════════════════════════════════════════════════════════════════════════
657
658/// One initial condition of an ODE initial-value problem:
659/// **`y^(order)(x) = value`**, i.e. the `order`-th derivative of the
660/// unknown function, evaluated at the point `x`, equals `value`.
661///
662/// `order = 0` is the plain `y(x) = value`.  Used by
663/// [`Ex::solve_ode_ivp`].
664///
665/// ```
666/// use symplex::prelude::*;
667///
668/// let ctx = Context::new();
669/// // y'(0) = 1
670/// let ic = InitialCondition { order: 1, x: ctx.int(0), value: ctx.int(1) };
671/// assert_eq!(ic.order, 1);
672/// ```
673#[derive(Clone, Debug, PartialEq)]
674pub struct InitialCondition {
675    /// Derivative order `k` of the condition `y^(k)(x) = value`.
676    pub order: usize,
677    /// The point at which the derivative is prescribed.
678    pub x: Ex,
679    /// The prescribed value of `y^(order)` at `x`.
680    pub value: Ex,
681}
682
683impl Ex {
684    /// Solve the ODE `self = 0` for `func(var)` subject to initial
685    /// conditions.
686    ///
687    /// Each [`InitialCondition`] `{ order: k, x: x0, value }` means
688    /// `d^k func / d var^k (x0) = value` (`k = 0` is `func(x0) = value`).
689    /// The general solution is found with [`solve_ode`](Ex::solve_ode),
690    /// then the integration constants `C1, C2, …` are determined by
691    /// substituting the conditions and solving the resulting (usually
692    /// linear) system with [`linsolve`]; nonlinear constant equations are
693    /// handled one at a time with [`solve`](Ex::solve).  Constants not
694    /// pinned down by the conditions remain in the result.
695    ///
696    /// # Errors
697    ///
698    /// - [`SymplexError::ComputationFailed`] if the ODE cannot be solved
699    ///   or the constants cannot be determined.
700    /// - [`SymplexError::NoSolution`] if the initial conditions are
701    ///   contradictory.
702    ///
703    /// # Examples
704    ///
705    /// ```
706    /// use symplex::prelude::*;
707    ///
708    /// let ctx = Context::new();
709    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
710    /// // y'' + y = 0, y(0) = 0, y'(0) = 1  →  y = sin(x)
711    /// let ode = &y.formal_diff(&x).formal_diff(&x) + &y;
712    /// let sol = ode
713    ///     .solve_ode_ivp(
714    ///         &y,
715    ///         &x,
716    ///         &[
717    ///             InitialCondition { order: 0, x: ctx.int(0), value: ctx.int(0) },
718    ///             InitialCondition { order: 1, x: ctx.int(0), value: ctx.int(1) },
719    ///         ],
720    ///     )
721    ///     .unwrap();
722    /// assert_eq!(format!("{}", sol.simplify()), "sin(x)");
723    /// ```
724    pub fn solve_ode_ivp(
725        &self,
726        func: &Ex,
727        var: &Ex,
728        ics: &[InitialCondition],
729    ) -> Result<Ex, SymplexError> {
730        let func_id = self.checked_id(func);
731        let var_id = self.checked_id(var);
732        for ic in ics {
733            let _ = self.checked_id(&ic.x);
734            let _ = self.checked_id(&ic.value);
735        }
736
737        let (general, constants): (Ex, Vec<Ex>) = {
738            let mut inner = self.inner.write();
739            match crate::calculus::ode::dsolve(&mut inner.arena, self.raw_id(), func_id, var_id) {
740                Some(res) => {
741                    let sol = res.solution;
742                    let consts = res.constants.clone();
743                    drop(inner);
744                    (
745                        self.wrap(sol),
746                        consts.into_iter().map(|c| self.wrap(c)).collect(),
747                    )
748                }
749                None => {
750                    drop(inner);
751                    return Err(SymplexError::ComputationFailed {
752                        operation: "solve_ode_ivp",
753                        reason: "could not find the general solution of the ODE".into(),
754                    });
755                }
756            }
757        };
758        if general.has_unevaluated() {
759            return Err(SymplexError::ComputationFailed {
760                operation: "solve_ode_ivp",
761                reason: format!("general solution contains unevaluated forms: {general}"),
762            });
763        }
764        // Implicit solutions (still mentioning `func`) cannot be fitted.
765        if general.contains(func) {
766            return Err(SymplexError::ComputationFailed {
767                operation: "solve_ode_ivp",
768                reason: format!("general solution is implicit in {func}: {general}"),
769            });
770        }
771        apply_initial_conditions(&general, &constants, var, ics, "solve_ode_ivp")
772    }
773}
774
775impl Ex {
776    /// Solve the Riccati equation `self = 0`, i.e.
777    /// `y' = q₀(x) + q₁(x)·y + q₂(x)·y²`, given a known particular
778    /// solution `particular`.
779    ///
780    /// The substitution `y = y_p + 1/v` reduces the equation to the linear
781    /// ODE `v' + (q₁ + 2·q₂·y_p)·v = −q₂`; the result is `y_p + 1/v` with the
782    /// integration constant `C1`.
783    ///
784    /// # Errors
785    ///
786    /// - [`SymplexError::InvalidArgument`] if `self` is not a Riccati
787    ///   equation in `func` or `particular` does not satisfy it.
788    /// - [`SymplexError::ComputationFailed`] if the linear equation for
789    ///   `v` cannot be solved in closed form.
790    ///
791    /// # Examples
792    ///
793    /// ```
794    /// use symplex::prelude::*;
795    ///
796    /// let ctx = Context::new();
797    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
798    /// // y' = y² - 2/x² has the particular solution y = 1/x
799    /// let ode = &y.formal_diff(&x) - &y.powi(2) + &(&ctx.int(2) / &x.powi(2));
800    /// let sol = ode.solve_riccati(&y, &x, &(&ctx.int(1) / &x)).unwrap();
801    /// assert!(sol.contains(&ctx.symbol("C1")));
802    /// assert!(ode.check_ode_solution(&sol, &y, &x));
803    /// ```
804    pub fn solve_riccati(&self, func: &Ex, var: &Ex, particular: &Ex) -> Result<Ex, SymplexError> {
805        let func_id = self.checked_id(func);
806        let var_id = self.checked_id(var);
807        let part_id = self.checked_id(particular);
808        let mut inner = self.inner.write();
809        match crate::calculus::ode::solve_riccati(
810            &mut inner.arena,
811            self.raw_id(),
812            func_id,
813            var_id,
814            part_id,
815        ) {
816            Some(res) => {
817                let sol = res.solution;
818                drop(inner);
819                let sol = self.wrap(sol);
820                if sol.has_unevaluated() {
821                    return Err(SymplexError::ComputationFailed {
822                        operation: "solve_riccati",
823                        reason: format!(
824                            "linear equation for the substitution could not be solved in closed form: {sol}"
825                        ),
826                    });
827                }
828                Ok(sol)
829            }
830            None => {
831                drop(inner);
832                Err(SymplexError::InvalidArgument {
833                    operation: "solve_riccati",
834                    reason: format!(
835                        "not a Riccati equation in {func}, or {particular} is not a particular solution"
836                    ),
837                })
838            }
839        }
840    }
841}
842
843/// Fit integration constants to initial conditions `y^(order)(x) = value`.
844pub(crate) fn apply_initial_conditions(
845    general: &Ex,
846    constants: &[Ex],
847    var: &Ex,
848    ics: &[InitialCondition],
849    operation: &'static str,
850) -> Result<Ex, SymplexError> {
851    if ics.is_empty() || constants.is_empty() {
852        return Ok(general.clone());
853    }
854    // Build one equation per initial condition.
855    let mut eqs: Vec<Ex> = Vec::with_capacity(ics.len());
856    for ic in ics {
857        let mut d = general.clone();
858        for _ in 0..ic.order {
859            d = d.diff(var);
860        }
861        let at = d.subs(var, &ic.x).eval();
862        eqs.push((&at - &ic.value).eval());
863    }
864    fit_constants(general, constants, &eqs, operation)
865}
866
867/// Solve `eqs = 0` for `constants` (linear first, then one-at-a-time) and
868/// substitute into `general`.
869pub(crate) fn fit_constants(
870    general: &Ex,
871    constants: &[Ex],
872    eqs: &[Ex],
873    operation: &'static str,
874) -> Result<Ex, SymplexError> {
875    // Only constants that actually appear matter.
876    let present: Vec<Ex> = constants
877        .iter()
878        .filter(|c| general.contains(c) || eqs.iter().any(|e| e.contains(c)))
879        .cloned()
880        .collect();
881    if present.is_empty() {
882        return Ok(general.clone());
883    }
884    match linsolve(eqs, &present) {
885        Ok(LinearSolution::Inconsistent) => Err(SymplexError::NoSolution {
886            operation,
887            reason: "initial conditions are contradictory".into(),
888        }),
889        Ok(LinearSolution::Unique(pairs)) => {
890            let mut sol = general.clone();
891            for (c, v) in &pairs {
892                sol = sol.subs(c, v);
893            }
894            // Substituting algebraic constants can swell the expression;
895            // bound the work before simplifying.
896            crate::domains::matrix::budget_check([&sol], operation)?;
897            let sol = sol.eval();
898            crate::domains::matrix::budget_check([&sol], operation)?;
899            Ok(sol.simplify())
900        }
901        Ok(LinearSolution::Parametric { solution, free }) => {
902            let mut sol = general.clone();
903            for (c, v) in &solution {
904                if !free.contains(c) {
905                    sol = sol.subs(c, v);
906                }
907            }
908            crate::domains::matrix::budget_check([&sol], operation)?;
909            Ok(sol.eval().simplify())
910        }
911        Err(_) => {
912            // Nonlinear in the constants: solve sequentially.
913            let mut sol = general.clone();
914            let mut remaining: Vec<Ex> = eqs.to_vec();
915            let mut unsolved: Vec<Ex> = present.clone();
916            while let Some(pos) = remaining.iter().position(|e| !e.is_zero_structural()) {
917                let eq = remaining.remove(pos);
918                let eq = eq.eval();
919                if eq.is_zero_structural() {
920                    continue;
921                }
922                let target = unsolved
923                    .iter()
924                    .position(|c| eq.contains(c))
925                    .ok_or_else(|| SymplexError::NoSolution {
926                        operation,
927                        reason: format!("initial conditions are contradictory: {eq} = 0"),
928                    })?;
929                let c = unsolved.remove(target);
930                let roots = eq.solve(&c).map_err(|e| SymplexError::ComputationFailed {
931                    operation,
932                    reason: format!("could not solve for {c}: {e}"),
933                })?;
934                let value =
935                    roots
936                        .first()
937                        .cloned()
938                        .ok_or_else(|| SymplexError::ComputationFailed {
939                            operation,
940                            reason: format!("no value of {c} satisfies {eq} = 0"),
941                        })?;
942                sol = sol.subs(&c, &value);
943                remaining = remaining
944                    .iter()
945                    .map(|e| e.subs(&c, &value).eval())
946                    .collect();
947            }
948            Ok(sol.eval().simplify())
949        }
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    #[test]
958    fn fresh_symbol_avoids_existing_names() {
959        let ctx = Context::new();
960        let n = ctx.symbol("n");
961        let fresh = fresh_symbol(&ctx, "n", &[Assumption::Integer]);
962        assert_ne!(fresh, n);
963        assert_eq!(format!("{fresh}"), "n1");
964        let fresh2 = fresh_symbol(&ctx, "n", &[Assumption::Integer]);
965        assert_eq!(format!("{fresh2}"), "n2");
966    }
967
968    #[test]
969    fn linsolve_inconsistent() {
970        let ctx = Context::new();
971        let x = ctx.symbol("x");
972        let sol = linsolve(&[&x - 1, &x - 2], std::slice::from_ref(&x)).unwrap();
973        assert!(sol.is_inconsistent());
974    }
975
976    #[test]
977    fn linsolve_rejects_nonlinear() {
978        let ctx = Context::new();
979        let x = ctx.symbol("x");
980        let r = linsolve(&[x.powi(2) - 1], std::slice::from_ref(&x));
981        assert!(matches!(r, Err(SymplexError::InvalidArgument { .. })));
982    }
983
984    #[test]
985    fn linsolve_accepts_equations() {
986        let ctx = Context::new();
987        let x = ctx.symbol("x");
988        let y = ctx.symbol("y");
989        let e1 = Equation::new(&x + &y, ctx.int(3));
990        let e2 = Equation::new(&x - &y, ctx.int(1));
991        let sol = linsolve(&[e1, e2], &[x.clone(), y.clone()]).unwrap();
992        assert_eq!(format!("{}", sol.get(&x).unwrap()), "2");
993        assert_eq!(format!("{}", sol.get(&y).unwrap()), "1");
994    }
995
996    #[test]
997    fn gauss_solve_basic() {
998        let a = vec![vec![2.0, 1.0], vec![1.0, 3.0]];
999        let b = vec![3.0, 5.0];
1000        let x = gauss_solve(a, b).unwrap();
1001        assert!((x[0] - 0.8).abs() < 1e-12 && (x[1] - 1.4).abs() < 1e-12);
1002    }
1003
1004    #[test]
1005    fn gauss_solve_singular() {
1006        let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
1007        assert!(gauss_solve(a, vec![1.0, 2.0]).is_none());
1008    }
1009}