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