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(¶m);
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(¶m));
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}