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