Skip to main content

oxilean_std/variational_calculus/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use super::functions::*;
6use oxilean_kernel::{BinderInfo, Declaration, Environment, Expr, Level, Name};
7
8/// Second variation δ²F of a functional, used to classify critical points.
9#[derive(Debug, Clone)]
10pub struct SecondVariation {
11    /// Name of the functional.
12    pub functional: String,
13    /// Whether the second variation operator is positive definite on the function space.
14    pub is_positive_definite: bool,
15}
16impl SecondVariation {
17    /// Create a new `SecondVariation` (defaults to positive definite).
18    pub fn new(functional: impl Into<String>, is_positive_definite: bool) -> Self {
19        Self {
20            functional: functional.into(),
21            is_positive_definite,
22        }
23    }
24    /// Legendre necessary condition: L_{ẋẋ} ≥ 0 along the extremal.
25    /// Returns `true` when `is_positive_definite` holds (sufficient form).
26    pub fn legendre_condition(&self) -> bool {
27        self.is_positive_definite
28    }
29    /// Jacobi sufficient condition: no conjugate points on the open interval.
30    /// Returns `true` when the second variation is positive definite.
31    pub fn jacobi_condition(&self) -> bool {
32        self.is_positive_definite
33    }
34}
35/// A conserved Noether charge associated to a continuous symmetry.
36#[derive(Debug, Clone)]
37pub struct ConservedQuantity {
38    /// Name of the conserved quantity (e.g., "energy", "momentum").
39    pub name: String,
40    /// The symmetry responsible for conservation.
41    pub symmetry: Symmetry,
42    /// Mathematical expression for the conserved charge.
43    pub expression: String,
44}
45impl ConservedQuantity {
46    /// Create a new `ConservedQuantity`.
47    pub fn new(name: impl Into<String>, symmetry: Symmetry, expression: impl Into<String>) -> Self {
48        Self {
49            name: name.into(),
50            symmetry,
51            expression: expression.into(),
52        }
53    }
54    /// A Noether charge is conserved on-shell (along solutions to the E-L equations).
55    pub fn is_conserved_on_shell(&self) -> bool {
56        self.symmetry.is_continuous
57    }
58}
59/// A collection of Morse critical points, enabling Morse inequality checks.
60#[allow(dead_code)]
61#[derive(Debug, Clone, Default)]
62pub struct MorseData {
63    /// The list of critical points.
64    pub critical_points: Vec<MorseCriticalPointData>,
65}
66impl MorseData {
67    /// Create an empty Morse data set.
68    pub fn new() -> Self {
69        Self::default()
70    }
71    /// Add a critical point.
72    pub fn add_critical_point(&mut self, cp: MorseCriticalPointData) {
73        self.critical_points.push(cp);
74    }
75    /// Count critical points of each Morse index. Returns a vector C_k.
76    pub fn morse_count_by_index(&self) -> Vec<usize> {
77        let max_idx = self
78            .critical_points
79            .iter()
80            .map(|cp| cp.morse_index)
81            .max()
82            .unwrap_or(0);
83        let mut counts = vec![0usize; max_idx + 1];
84        for cp in &self.critical_points {
85            counts[cp.morse_index] += 1;
86        }
87        counts
88    }
89    /// Alternating sum Σ_k (-1)^k C_k — equals the Euler characteristic by Morse theory.
90    pub fn euler_characteristic(&self) -> i64 {
91        self.critical_points
92            .iter()
93            .map(|cp| cp.euler_contribution())
94            .sum()
95    }
96    /// Verify the weak Morse inequality C_k ≥ b_k for given Betti numbers.
97    pub fn check_weak_morse_inequality(&self, betti: &[usize]) -> bool {
98        let counts = self.morse_count_by_index();
99        betti
100            .iter()
101            .enumerate()
102            .all(|(k, &b)| counts.get(k).copied().unwrap_or(0) >= b)
103    }
104}
105/// Data for a Noether symmetry and its associated conserved current in PDE setting.
106#[allow(dead_code)]
107#[derive(Debug, Clone)]
108pub struct NoetherSymmetryData {
109    /// Name of the symmetry (e.g., "time translation", "U(1) phase rotation").
110    pub name: String,
111    /// Generator vector field components (as strings).
112    pub generator: Vec<String>,
113    /// Associated conserved current J^μ = (J^0, J^1, …) (as strings).
114    pub conserved_current: Vec<String>,
115    /// Associated conserved charge Q = ∫ J^0 dx (as a string expression).
116    pub conserved_charge: String,
117    /// Whether this symmetry is a gauge (local) symmetry vs. global.
118    pub is_gauge: bool,
119}
120impl NoetherSymmetryData {
121    /// Create a new Noether symmetry record.
122    pub fn new(name: &str, generator: Vec<String>, conserved_charge: &str, is_gauge: bool) -> Self {
123        Self {
124            name: name.to_string(),
125            generator,
126            conserved_current: Vec::new(),
127            conserved_charge: conserved_charge.to_string(),
128            is_gauge,
129        }
130    }
131    /// Set the conserved current components.
132    pub fn set_current(&mut self, current: Vec<String>) {
133        self.conserved_current = current;
134    }
135    /// A global symmetry produces an on-shell conserved charge.
136    pub fn is_on_shell_conserved(&self) -> bool {
137        !self.is_gauge
138    }
139    /// Return the divergence-free condition as a string.
140    pub fn divergence_free_condition(&self) -> String {
141        let n = self.conserved_current.len();
142        if n == 0 {
143            return format!("div J = 0  [{}]", self.name);
144        }
145        let terms: Vec<String> = (0..n).map(|mu| format!("∂_{mu}(J^{mu})")).collect();
146        format!("{} = 0", terms.join(" + "))
147    }
148    /// Standard energy-momentum conserved charge expression.
149    pub fn energy_string(&self) -> String {
150        format!("E = ∫ T^{{00}} d^3x  [symmetry: {}]", self.name)
151    }
152}
153/// Find a minimal surface spanning a planar boundary polygon
154/// using a discrete Douglas-type energy minimisation.
155///
156/// We parameterise the surface over a unit square [0,1]² and minimise
157/// the Dirichlet energy E[u] = ∫∫ (|∂u/∂x|² + |∂u/∂y|²) dx dy
158/// (harmonic maps are conformal parametrisations of minimal surfaces).
159#[derive(Debug, Clone)]
160pub struct MinimalSurfaceFinder {
161    /// Grid resolution N: the interior has (N-1)² nodes.
162    pub resolution: usize,
163    /// Boundary values on the four edges (N samples per edge).
164    pub boundary: Vec<f64>,
165}
166impl MinimalSurfaceFinder {
167    /// Create a minimal surface finder for an N×N grid.
168    pub fn new(resolution: usize) -> Self {
169        let n = resolution;
170        let mut boundary = vec![0.0_f64; 4 * n];
171        for k in 0..n {
172            let t = k as f64 / (n - 1) as f64;
173            let angle = t * std::f64::consts::PI * 0.5;
174            boundary[k] = angle.cos();
175            boundary[n + k] = angle.sin();
176            boundary[2 * n + k] = -(angle.cos());
177            boundary[3 * n + k] = -(angle.sin());
178        }
179        Self {
180            resolution,
181            boundary,
182        }
183    }
184    /// Solve for the interior values u[i][j] using successive over-relaxation (SOR).
185    /// Minimises the discrete Dirichlet energy: E = Σ (u_i,j+1 − u_i,j)² + (u_i+1,j − u_i,j)².
186    pub fn solve(&self, max_iter: usize, omega: f64) -> Vec<Vec<f64>> {
187        let n = self.resolution;
188        let ni = if n > 2 { n - 2 } else { 0 };
189        let mut u = vec![vec![0.0_f64; n]; n];
190        let bv = 1.0_f64;
191        for j in 0..n {
192            u[0][j] = bv;
193            u[n - 1][j] = bv;
194        }
195        for i in 0..n {
196            u[i][0] = bv;
197            u[i][n - 1] = bv;
198        }
199        for _ in 0..max_iter {
200            for i in 1..=ni {
201                for j in 1..=ni {
202                    let avg = (u[i - 1][j] + u[i + 1][j] + u[i][j - 1] + u[i][j + 1]) / 4.0;
203                    u[i][j] = (1.0 - omega) * u[i][j] + omega * avg;
204                }
205            }
206        }
207        u
208    }
209    /// Compute the discrete Dirichlet energy for the solution grid.
210    pub fn dirichlet_energy(&self, u: &[Vec<f64>]) -> f64 {
211        let n = u.len();
212        if n < 2 {
213            return 0.0;
214        }
215        let mut energy = 0.0;
216        for i in 0..n {
217            for j in 0..n {
218                if i + 1 < n {
219                    energy += (u[i + 1][j] - u[i][j]).powi(2);
220                }
221                if j + 1 < n {
222                    energy += (u[i][j + 1] - u[i][j]).powi(2);
223                }
224            }
225        }
226        energy * 0.5
227    }
228}
229/// Action functional S[q] = ∫_{t0}^{t1} L(q, q̇, t) dt.
230#[derive(Debug, Clone)]
231pub struct ActionFunctional {
232    /// The Lagrangian expression (as a string).
233    pub lagrangian: String,
234    /// Start time t₀.
235    pub time_start: f64,
236    /// End time t₁.
237    pub time_end: f64,
238}
239impl ActionFunctional {
240    /// Create a new action functional with Lagrangian `L` on `[t0, t1]`.
241    pub fn new(lagrangian: impl Into<String>, time_start: f64, time_end: f64) -> Self {
242        Self {
243            lagrangian: lagrangian.into(),
244            time_start,
245            time_end,
246        }
247    }
248    /// Returns `true` if the time interval is non-degenerate (t1 > t0).
249    /// For physical systems, the action is typically bounded below on bounded intervals.
250    pub fn is_bounded_below(&self) -> bool {
251        self.time_end > self.time_start
252    }
253}
254/// Compute a geodesic on a surface of revolution given by r = f(z),
255/// using the variational arc-length solver in the (z, θ) parametrisation.
256///
257/// For a surface of revolution parametrised as (r(z) cos θ, r(z) sin θ, z),
258/// the arc-length element is ds² = (1 + r'²) dz² + r² dθ².
259/// A geodesic with fixed endpoints (z₀, θ₀) and (z₁, θ₁) satisfies the
260/// E-L equation for the arc-length functional.
261#[derive(Debug, Clone)]
262pub struct GeodesicOnSurface {
263    /// Profile function r(z) given as a vector of (z, r) sample pairs.
264    pub profile: Vec<(f64, f64)>,
265    /// Start point (z₀, θ₀).
266    pub start: (f64, f64),
267    /// End point (z₁, θ₁).
268    pub end: (f64, f64),
269    /// Number of interior discretisation nodes.
270    pub n_nodes: usize,
271}
272impl GeodesicOnSurface {
273    /// Create a new geodesic problem on a surface of revolution.
274    pub fn new(
275        profile: Vec<(f64, f64)>,
276        start: (f64, f64),
277        end: (f64, f64),
278        n_nodes: usize,
279    ) -> Self {
280        Self {
281            profile,
282            start,
283            end,
284            n_nodes,
285        }
286    }
287    /// Linearly interpolate r(z) from the profile.
288    pub fn r_at(&self, z: f64) -> f64 {
289        if self.profile.len() < 2 {
290            return 1.0;
291        }
292        let z0 = self.profile[0].0;
293        let z1 = self.profile[self.profile.len() - 1].0;
294        let t = (z - z0) / (z1 - z0).max(f64::EPSILON);
295        let t = t.clamp(0.0, 1.0);
296        let idx = ((t * (self.profile.len() - 1) as f64) as usize).min(self.profile.len() - 2);
297        let (za, ra) = self.profile[idx];
298        let (zb, rb) = self.profile[idx + 1];
299        let u = (z - za) / (zb - za).max(f64::EPSILON);
300        ra + u * (rb - ra)
301    }
302    /// Use the E-L solver to find the θ(z) component of the geodesic.
303    ///
304    /// The Lagrangian for the θ-coordinate is:
305    /// L = sqrt( (1 + r'(z)²) + r(z)² * θ'(z)² )
306    /// projected to the θ equation.  Here we use a simplified weighted
307    /// arc-length solver.
308    pub fn solve(&self, max_iter: usize) -> Vec<(f64, f64)> {
309        let n = self.n_nodes;
310        let (z0, theta0) = self.start;
311        let (z1, theta1) = self.end;
312        let hz = (z1 - z0) / (n as f64 + 1.0);
313        let mut theta = vec![0.0_f64; n];
314        for i in 0..n {
315            let t = (i + 1) as f64 / (n as f64 + 1.0);
316            theta[i] = theta0 + t * (theta1 - theta0);
317        }
318        let tau = 1e-4_f64;
319        for _ in 0..max_iter {
320            let mut grad = vec![0.0_f64; n];
321            for i in 0..n {
322                let z = z0 + (i + 1) as f64 * hz;
323                let r = self.r_at(z);
324                let th_left = if i == 0 { theta0 } else { theta[i - 1] };
325                let th_right = if i == n - 1 { theta1 } else { theta[i + 1] };
326                let s_r = (th_right - theta[i]) / hz;
327                let s_l = (theta[i] - th_left) / hz;
328                let w = r * r;
329                grad[i] = -(w * s_r / (1.0 + w * s_r * s_r).sqrt()
330                    - w * s_l / (1.0 + w * s_l * s_l).sqrt())
331                    / hz;
332            }
333            for i in 0..n {
334                theta[i] -= tau * grad[i];
335            }
336        }
337        let mut result = Vec::with_capacity(n + 2);
338        result.push((z0, theta0));
339        for i in 0..n {
340            result.push((z0 + (i + 1) as f64 * hz, theta[i]));
341        }
342        result.push((z1, theta1));
343        result
344    }
345}
346/// Exact 1D optimal transport cost W_p^p(μ, ν) using the quantile coupling.
347///
348/// For two empirical distributions given by sorted sample vectors, the optimal
349/// coupling is the monotone one: T(x_i) = y_i when both are sorted.
350#[derive(Debug, Clone)]
351pub struct OptimalTransportCost {
352    /// Exponent p ≥ 1 for the cost c(x, y) = |x − y|^p.
353    pub p: f64,
354    /// Sorted samples from the source measure μ.
355    pub source: Vec<f64>,
356    /// Sorted samples from the target measure ν.
357    pub target: Vec<f64>,
358}
359impl OptimalTransportCost {
360    /// Create a new OT cost from (potentially unsorted) sample vectors.
361    pub fn new(p: f64, mut source: Vec<f64>, mut target: Vec<f64>) -> Self {
362        source.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
363        target.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
364        Self { p, source, target }
365    }
366    /// Compute the exact W_p^p cost using the sorted coupling.
367    ///
368    /// Requires source.len() == target.len().
369    pub fn compute(&self) -> f64 {
370        let n = self.source.len().min(self.target.len());
371        if n == 0 {
372            return 0.0;
373        }
374        self.source[..n]
375            .iter()
376            .zip(self.target[..n].iter())
377            .map(|(x, y)| (x - y).abs().powf(self.p))
378            .sum::<f64>()
379            / n as f64
380    }
381    /// Wasserstein-1 distance (p = 1) via the L¹ formula.
382    pub fn w1_distance(&self) -> f64 {
383        let n = self.source.len().min(self.target.len());
384        if n == 0 {
385            return 0.0;
386        }
387        self.source[..n]
388            .iter()
389            .zip(self.target[..n].iter())
390            .map(|(x, y)| (x - y).abs())
391            .sum::<f64>()
392            / n as f64
393    }
394    /// Wasserstein-2 distance (p = 2).
395    pub fn w2_distance(&self) -> f64 {
396        let n = self.source.len().min(self.target.len());
397        if n == 0 {
398            return 0.0;
399        }
400        let w2sq = self.source[..n]
401            .iter()
402            .zip(self.target[..n].iter())
403            .map(|(x, y)| (x - y).powi(2))
404            .sum::<f64>()
405            / n as f64;
406        w2sq.sqrt()
407    }
408}
409/// A Lagrangian function L(q_1, ..., q_n, q̇_1, ..., q̇_n, t).
410#[derive(Debug, Clone)]
411pub struct LagrangianFunction {
412    /// Name of the Lagrangian.
413    pub name: String,
414    /// List of generalized coordinate names.
415    pub vars: Vec<String>,
416    /// Number of degrees of freedom.
417    pub n_dof: usize,
418}
419impl LagrangianFunction {
420    /// Create a new Lagrangian for `n_dof` degrees of freedom.
421    pub fn new(n_dof: usize) -> Self {
422        let vars = (0..n_dof).map(|i| format!("q_{i}")).collect();
423        Self {
424            name: format!("L_{n_dof}dof"),
425            vars,
426            n_dof,
427        }
428    }
429    /// Return the Euler-Lagrange equation in string form for each degree of freedom.
430    ///
431    /// E-L: d/dt (∂L/∂q̇_i) - ∂L/∂q_i = 0  for i = 1, ..., n.
432    pub fn euler_lagrange_equation_string(&self) -> String {
433        let eqs: Vec<String> = (0..self.n_dof)
434            .map(|i| format!("d/dt(∂L/∂q̇_{i}) - ∂L/∂q_{i} = 0"))
435            .collect();
436        eqs.join("; ")
437    }
438}
439/// Finite-difference Euler-Lagrange solver for 1D problems.
440///
441/// Minimises J[y] = ∫_a^b L(x, y, y') dx subject to y(a) = ya, y(b) = yb
442/// using a gradient-descent iteration on the interior grid nodes.
443#[derive(Debug, Clone)]
444pub struct EulerLagrangeSolver {
445    /// Left endpoint a.
446    pub a: f64,
447    /// Right endpoint b.
448    pub b: f64,
449    /// Boundary value y(a).
450    pub ya: f64,
451    /// Boundary value y(b).
452    pub yb: f64,
453    /// Number of interior grid points.
454    pub n_interior: usize,
455    /// Step size τ for gradient descent.
456    pub step_size: f64,
457}
458impl EulerLagrangeSolver {
459    /// Create a new solver on [a, b] with n+1 subintervals.
460    pub fn new(a: f64, b: f64, ya: f64, yb: f64, n_interior: usize, step_size: f64) -> Self {
461        Self {
462            a,
463            b,
464            ya,
465            yb,
466            n_interior,
467            step_size,
468        }
469    }
470    /// Grid spacing h = (b − a) / (n + 1).
471    pub fn h(&self) -> f64 {
472        (self.b - self.a) / (self.n_interior as f64 + 1.0)
473    }
474    /// Linear initial guess interpolating the boundary conditions.
475    pub fn linear_initial_guess(&self) -> Vec<f64> {
476        let n = self.n_interior;
477        let h = self.h();
478        (1..=(n as u64))
479            .map(|k| {
480                let x = self.a + k as f64 * h;
481                let t = (x - self.a) / (self.b - self.a);
482                self.ya + t * (self.yb - self.ya)
483            })
484            .collect()
485    }
486    /// Run `max_iter` gradient-descent steps minimising the arc-length functional
487    /// J[y] = ∫ sqrt(1 + y'²) dx (geodesic in the plane).
488    ///
489    /// Returns the interior y-values at convergence.
490    pub fn solve_arc_length(&self, max_iter: usize) -> Vec<f64> {
491        let n = self.n_interior;
492        let h = self.h();
493        let mut y = self.linear_initial_guess();
494        for _ in 0..max_iter {
495            let mut grad = vec![0.0_f64; n];
496            for i in 0..n {
497                let y_left = if i == 0 { self.ya } else { y[i - 1] };
498                let y_right = if i == n - 1 { self.yb } else { y[i + 1] };
499                let s_r = (y_right - y[i]) / h;
500                let s_l = (y[i] - y_left) / h;
501                grad[i] = -(s_r / (1.0 + s_r * s_r).sqrt() - s_l / (1.0 + s_l * s_l).sqrt()) / h;
502            }
503            for i in 0..n {
504                y[i] -= self.step_size * grad[i];
505            }
506        }
507        y
508    }
509    /// Evaluate the discrete arc-length functional on the given interior values.
510    pub fn arc_length(&self, y_interior: &[f64]) -> f64 {
511        let h = self.h();
512        let n = y_interior.len();
513        let mut total = 0.0;
514        for i in 0..=n {
515            let y_left = if i == 0 { self.ya } else { y_interior[i - 1] };
516            let y_right = if i == n { self.yb } else { y_interior[i] };
517            let slope = (y_right - y_left) / h;
518            total += h * (1.0 + slope * slope).sqrt();
519        }
520        total
521    }
522}
523/// Minimal surface problem: find a surface of least area spanning a given boundary curve.
524#[derive(Debug, Clone)]
525pub struct MinimalSurfaceProblem {
526    /// Description of the boundary curve.
527    pub boundary: String,
528}
529impl MinimalSurfaceProblem {
530    /// Create a new `MinimalSurfaceProblem` with the given boundary.
531    pub fn new(boundary: impl Into<String>) -> Self {
532        Self {
533            boundary: boundary.into(),
534        }
535    }
536    /// The Euler-Lagrange equation for minimal surfaces is the mean curvature equation H = 0.
537    /// In Monge form z = u(x,y):  (1+u_y²)u_xx - 2 u_x u_y u_xy + (1+u_x²)u_yy = 0.
538    pub fn euler_lagrange_equation(&self) -> String {
539        "(1 + u_y^2) * u_xx - 2 * u_x * u_y * u_xy + (1 + u_x^2) * u_yy = 0  (H = 0)".to_string()
540    }
541    /// Lower bound on minimal surface area: a minimal surface has area ≥ 0.
542    pub fn minimal_surface_area_lower_bound(&self) -> f64 {
543        0.0
544    }
545}
546/// A functional that is lower semicontinuous with respect to a given topology.
547#[derive(Debug, Clone)]
548pub struct LowerSemicontinuous {
549    /// Name or expression for the functional F.
550    pub functional: String,
551    /// Description of the topology (e.g., "weak topology of W^{1,2}").
552    pub topology: String,
553}
554impl LowerSemicontinuous {
555    /// Create a new `LowerSemicontinuous` object.
556    pub fn new(functional: impl Into<String>, topology: impl Into<String>) -> Self {
557        Self {
558            functional: functional.into(),
559            topology: topology.into(),
560        }
561    }
562    /// The direct method applies when F is coercive and weakly lower semicontinuous:
563    /// every minimizing sequence has a weakly convergent subsequence whose limit is a minimizer.
564    pub fn direct_method_applicable(&self) -> bool {
565        true
566    }
567}
568/// Wasserstein gradient flow simulation using the JKO (Jordan-Kinderlehrer-Otto)
569/// minimising movement scheme.
570///
571/// Evolves a discrete probability measure ρ = (x_i, w_i) under the gradient
572/// flow of the free energy F[ρ] = ∫ ρ log ρ dx + ∫ V ρ dx (Fokker-Planck).
573#[derive(Debug, Clone)]
574pub struct WassersteinGradientFlow {
575    /// JKO time step τ.
576    pub tau: f64,
577    /// Current particle positions x_i.
578    pub positions: Vec<f64>,
579    /// Equal weights w_i = 1/N.
580    pub n_particles: usize,
581    /// Potential V(x) = α x² / 2 (quadratic confining potential), coefficient α.
582    pub potential_alpha: f64,
583}
584impl WassersteinGradientFlow {
585    /// Initialise with N Gaussian-distributed particles.
586    pub fn new(n_particles: usize, tau: f64, potential_alpha: f64) -> Self {
587        let positions: Vec<f64> = (0..n_particles)
588            .map(|i| {
589                let p = (i as f64 + 0.5) / n_particles as f64;
590                let t = (-2.0 * (p * (1.0 - p)).ln()).sqrt();
591                let c0 = 2.515517_f64;
592                let c1 = 0.802853_f64;
593                let c2 = 0.010328_f64;
594                let d1 = 1.432788_f64;
595                let d2 = 0.189269_f64;
596                let d3 = 0.001308_f64;
597                let num = c0 + c1 * t + c2 * t * t;
598                let den = 1.0 + d1 * t + d2 * t * t + d3 * t * t * t;
599                if p < 0.5 {
600                    -(t - num / den)
601                } else {
602                    t - num / den
603                }
604            })
605            .collect();
606        Self {
607            tau,
608            positions,
609            n_particles,
610            potential_alpha,
611        }
612    }
613    /// Perform one JKO step: move particles to minimise
614    /// τ F[ρ] + W_2²(ρ^n, ρ) / 2
615    /// via a proximal operator.  For the quadratic potential V = α x²/2 the
616    /// JKO update has the closed-form shrinkage:
617    ///   x_i^{n+1} = x_i^n / (1 + τ α).
618    pub fn step(&mut self) {
619        let factor = 1.0 / (1.0 + self.tau * self.potential_alpha);
620        for x in self.positions.iter_mut() {
621            *x *= factor;
622        }
623    }
624    /// Run `n_steps` JKO steps.
625    pub fn run(&mut self, n_steps: usize) {
626        for _ in 0..n_steps {
627            self.step();
628        }
629    }
630    /// Compute the empirical mean of the particle cloud.
631    pub fn mean(&self) -> f64 {
632        self.positions.iter().sum::<f64>() / self.n_particles as f64
633    }
634    /// Compute the empirical variance of the particle cloud.
635    pub fn variance(&self) -> f64 {
636        let mu = self.mean();
637        self.positions.iter().map(|x| (x - mu).powi(2)).sum::<f64>() / self.n_particles as f64
638    }
639    /// Steady-state variance: σ² = 1/(τ α N) · N = 1/α (Gaussian with covariance 1/α).
640    pub fn steady_state_variance(&self) -> f64 {
641        if self.potential_alpha.abs() < f64::EPSILON {
642            f64::INFINITY
643        } else {
644            1.0 / self.potential_alpha
645        }
646    }
647}
648/// Geodesic equation in a Riemannian manifold: curves minimizing arc length.
649#[derive(Debug, Clone)]
650pub struct GeodesicEquation {
651    /// Description of the metric tensor g_{ij}.
652    pub metric: String,
653    /// Dimension of the manifold.
654    pub dimension: usize,
655}
656impl GeodesicEquation {
657    /// Create a new `GeodesicEquation` for a manifold of given `dimension`.
658    pub fn new(dimension: usize) -> Self {
659        Self {
660            metric: format!("g_{{ij}} on R^{dimension}"),
661            dimension,
662        }
663    }
664    /// Christoffel symbols Γ^k_{ij} = (1/2) g^{kl} (∂_i g_{jl} + ∂_j g_{il} - ∂_l g_{ij}).
665    pub fn christoffel_symbols_string(&self) -> String {
666        format!(
667            "Gamma^k_{{ij}} = (1/2) g^{{kl}} (partial_i g_{{jl}} + partial_j g_{{il}} - partial_l g_{{ij}}) \
668             for i,j,k,l in 1..{}",
669            self.dimension
670        )
671    }
672    /// In flat Euclidean space (g_{ij} = δ_{ij}) the geodesics are straight lines.
673    pub fn is_straight_line_in_flat_space(&self) -> bool {
674        true
675    }
676}
677/// Brachistochrone problem: find the curve of fastest descent between two points.
678#[derive(Debug, Clone)]
679pub struct BrachistochroneProblem {
680    /// Starting point (x₀, y₀).
681    pub start: (f64, f64),
682    /// Ending point (x₁, y₁).
683    pub end: (f64, f64),
684}
685impl BrachistochroneProblem {
686    /// Create a new brachistochrone problem from `start` to `end`.
687    pub fn new(start: (f64, f64), end: (f64, f64)) -> Self {
688        Self { start, end }
689    }
690    /// The optimal curve is a cycloid: x = R(θ - sin θ), y = R(1 - cos θ).
691    pub fn cycloid_solution(&self) -> String {
692        "x(theta) = R * (theta - sin(theta)), \
693         y(theta) = R * (1 - cos(theta)), \
694         where R is chosen so the cycloid passes through the endpoint."
695            .to_string()
696    }
697    /// Approximate travel time on the optimal cycloid (proportional to sqrt(R/g) * π).
698    /// Uses a simple estimate based on the vertical drop.
699    pub fn time_on_cycloid(&self) -> f64 {
700        let dy = (self.end.1 - self.start.1).abs();
701        if dy < f64::EPSILON {
702            return 0.0;
703        }
704        let g = 9.81_f64;
705        let r = dy / 2.0;
706        std::f64::consts::PI * (r / g).sqrt()
707    }
708}
709/// Classification of continuous symmetry types.
710#[derive(Debug, Clone, PartialEq, Eq)]
711pub enum SymmetryType {
712    /// Invariance under time translations → energy conservation.
713    TimeTranslation,
714    /// Invariance under spatial translations → momentum conservation.
715    SpaceTranslation,
716    /// Invariance under rotations → angular momentum conservation.
717    Rotation,
718    /// Lorentz boost symmetry → centre-of-mass motion conserved.
719    Boost,
720    /// Internal (gauge) symmetry → charge conservation.
721    Internal,
722}
723/// Discrete approximation to the Yang-Mills energy on a 2D lattice gauge field.
724///
725/// The lattice Yang-Mills energy is E = Σ_{plaquettes} (1 − Re tr U_p)
726/// where U_p is the product of gauge matrices around each plaquette.
727#[allow(dead_code)]
728#[derive(Debug, Clone)]
729pub struct YangMillsEnergy {
730    /// Grid size N: an N×N lattice.
731    pub n: usize,
732    /// Link variables U_{i,j,dir} ∈ U(1), stored as angles θ ∈ [0, 2π).
733    /// `links[i * n + j][dir]` = θ at site (i,j), direction dir (0=right, 1=up).
734    pub links: Vec<[f64; 2]>,
735}
736impl YangMillsEnergy {
737    /// Create a new lattice with all links set to the identity (θ = 0).
738    pub fn new(n: usize) -> Self {
739        Self {
740            n,
741            links: vec![[0.0; 2]; n * n],
742        }
743    }
744    /// Set the link angle at site (i,j) in direction dir.
745    pub fn set_link(&mut self, i: usize, j: usize, dir: usize, theta: f64) {
746        if i < self.n && j < self.n && dir < 2 {
747            self.links[i * self.n + j][dir] = theta;
748        }
749    }
750    /// Get the link angle at site (i,j) in direction dir.
751    pub fn get_link(&self, i: usize, j: usize, dir: usize) -> f64 {
752        if i < self.n && j < self.n && dir < 2 {
753            self.links[i * self.n + j][dir]
754        } else {
755            0.0
756        }
757    }
758    /// Plaquette angle at site (i,j): θ_p = θ_{i,j,0} + θ_{i+1,j,1} − θ_{i,j+1,0} − θ_{i,j,1}.
759    pub fn plaquette_angle(&self, i: usize, j: usize) -> f64 {
760        let n = self.n;
761        let i1 = (i + 1) % n;
762        let j1 = (j + 1) % n;
763        let a = self.get_link(i, j, 0);
764        let b = self.get_link(i1, j, 1);
765        let c = self.get_link(i, j1, 0);
766        let d = self.get_link(i, j, 1);
767        a + b - c - d
768    }
769    /// Total Yang-Mills energy: E = Σ_{i,j} (1 − cos(θ_p)).
770    pub fn total_energy(&self) -> f64 {
771        let n = self.n;
772        (0..n)
773            .flat_map(|i| (0..n).map(move |j| (i, j)))
774            .map(|(i, j)| 1.0 - self.plaquette_angle(i, j).cos())
775            .sum()
776    }
777    /// Perform one step of gradient descent on all link variables to minimise the energy.
778    pub fn gradient_descent_step(&mut self, step_size: f64) {
779        let n = self.n;
780        let mut grad = vec![[0.0_f64; 2]; n * n];
781        for i in 0..n {
782            for j in 0..n {
783                let theta_p = self.plaquette_angle(i, j);
784                let sin_p = theta_p.sin();
785                let j_prev = if j == 0 { n - 1 } else { j - 1 };
786                let theta_p_prev = self.plaquette_angle(i, j_prev);
787                grad[i * n + j][0] += sin_p - theta_p_prev.sin();
788                let i_prev = if i == 0 { n - 1 } else { i - 1 };
789                let theta_p_iprev = self.plaquette_angle(i_prev, j);
790                grad[i * n + j][1] += sin_p - theta_p_iprev.sin();
791            }
792        }
793        for k in 0..(n * n) {
794            self.links[k][0] -= step_size * grad[k][0];
795            self.links[k][1] -= step_size * grad[k][1];
796        }
797    }
798}
799/// Stores data about a critical point of a functional for use in Morse theory.
800#[allow(dead_code)]
801#[derive(Debug, Clone)]
802pub struct MorseCriticalPointData {
803    /// Label for this critical point.
804    pub label: String,
805    /// Critical value F(u*).
806    pub critical_value: f64,
807    /// Morse index (number of negative directions of the Hessian).
808    pub morse_index: usize,
809    /// Whether the critical point is non-degenerate (Hessian is invertible).
810    pub non_degenerate: bool,
811}
812impl MorseCriticalPointData {
813    /// Create a new critical point record.
814    pub fn new(label: &str, critical_value: f64, morse_index: usize, non_degenerate: bool) -> Self {
815        Self {
816            label: label.to_string(),
817            critical_value,
818            morse_index,
819            non_degenerate,
820        }
821    }
822    /// A non-degenerate critical point of index 0 is a local minimum.
823    pub fn is_local_minimum(&self) -> bool {
824        self.non_degenerate && self.morse_index == 0
825    }
826    /// A non-degenerate critical point of index k > 0 is a saddle point.
827    pub fn is_saddle_point(&self) -> bool {
828        self.non_degenerate && self.morse_index > 0
829    }
830    /// The contribution to the Euler characteristic: (−1)^{Morse index}.
831    pub fn euler_contribution(&self) -> i64 {
832        if self.morse_index % 2 == 0 {
833            1
834        } else {
835            -1
836        }
837    }
838}
839/// First variation δF[φ; η] of a functional F in direction η.
840#[derive(Debug, Clone)]
841pub struct FirstVariation {
842    /// Name of the functional being varied.
843    pub functional: String,
844    /// Name of the variation direction (test function).
845    pub direction: String,
846}
847impl FirstVariation {
848    /// Create a new `FirstVariation` record.
849    pub fn new(functional: impl Into<String>, direction: impl Into<String>) -> Self {
850        Self {
851            functional: functional.into(),
852            direction: direction.into(),
853        }
854    }
855    /// At a critical point, the first variation vanishes for all test functions.
856    /// Returns `true` (fundamental theorem of variational calculus).
857    pub fn vanishes_at_critical_point(&self) -> bool {
858        true
859    }
860}
861/// Sobolev space W^{k,p}(Ω): functions with k weak derivatives in L^p.
862#[derive(Debug, Clone)]
863pub struct SobolevSpace {
864    /// Description of the domain Ω.
865    pub domain: String,
866    /// Order of weak derivatives.
867    pub k: u32,
868    /// Integrability exponent p ≥ 1.
869    pub p: f64,
870}
871impl SobolevSpace {
872    /// Create the Sobolev space W^{k,p}(domain).
873    pub fn new(domain: impl Into<String>, k: u32, p: f64) -> Self {
874        Self {
875            domain: domain.into(),
876            k,
877            p,
878        }
879    }
880    /// Norm formula: ||u||_{W^{k,p}} = (Σ_{|α|≤k} ||D^α u||_{L^p}^p)^{1/p}.
881    pub fn norm_formula(&self) -> String {
882        format!(
883            "||u||_{{W^{{{},{}}}({})}} = (sum_{{|alpha|<={}}} ||D^alpha u||_{{L^{}}}^{})^{{1/{}}}",
884            self.k, self.p as u32, self.domain, self.k, self.p as u32, self.p as u32, self.p as u32
885        )
886    }
887    /// W^{k,2} = H^k is a Hilbert space; W^{k,p} with p ≠ 2 is only Banach.
888    pub fn is_hilbert_space(&self) -> bool {
889        self.k >= 1 && (self.p - 2.0).abs() < f64::EPSILON
890    }
891}
892/// A continuous symmetry of the action functional.
893#[derive(Debug, Clone)]
894pub struct Symmetry {
895    /// Human-readable name of the symmetry.
896    pub name: String,
897    /// Type of symmetry transformation.
898    pub transformation_type: SymmetryType,
899    /// Whether the symmetry is a continuous (Lie) symmetry.
900    pub is_continuous: bool,
901}
902impl Symmetry {
903    /// Create a new `Symmetry`.
904    pub fn new(
905        name: impl Into<String>,
906        transformation_type: SymmetryType,
907        is_continuous: bool,
908    ) -> Self {
909        Self {
910            name: name.into(),
911            transformation_type,
912            is_continuous,
913        }
914    }
915}
916/// Noether correspondence: one continuous symmetry ↔ one conserved charge.
917#[derive(Debug, Clone)]
918pub struct NoetherCorrespondence {
919    /// The symmetry.
920    pub symmetry: Symmetry,
921    /// The associated conserved charge.
922    pub conserved_charge: ConservedQuantity,
923}
924impl NoetherCorrespondence {
925    /// Build a `NoetherCorrespondence` from a symmetry and its conserved charge.
926    pub fn new(symmetry: Symmetry, conserved_charge: ConservedQuantity) -> Self {
927        Self {
928            symmetry,
929            conserved_charge,
930        }
931    }
932    /// Return the expression for the Noether current j^mu associated to this symmetry.
933    pub fn noether_current(&self) -> String {
934        format!(
935            "j^mu = (∂L/∂(∂_mu phi)) * delta_phi  [symmetry: {}]",
936            self.symmetry.name
937        )
938    }
939}
940/// A control system: dx/dt = f(x, u, t) with state x ∈ R^n, control u ∈ R^m.
941#[derive(Debug, Clone)]
942pub struct ControlSystem {
943    /// Dimension of the state space.
944    pub state_dim: usize,
945    /// Dimension of the control input.
946    pub control_dim: usize,
947    /// String description of the dynamics f(x, u, t).
948    pub dynamics: String,
949}
950impl ControlSystem {
951    /// Create a new control system with state dimension `n` and control dimension `m`.
952    pub fn new(state_dim: usize, control_dim: usize) -> Self {
953        Self {
954            state_dim,
955            control_dim,
956            dynamics: format!("dx/dt = f(x in R^{state_dim}, u in R^{control_dim}, t)"),
957        }
958    }
959    /// A system is (approximately) controllable if control_dim ≥ 1 and state_dim ≥ 1.
960    pub fn is_controllable(&self) -> bool {
961        self.control_dim >= 1 && self.state_dim >= 1
962    }
963    /// A system is (approximately) observable when the state dimension is positive.
964    pub fn is_observable(&self) -> bool {
965        self.state_dim >= 1
966    }
967}
968/// Isoperimetric problem: extremize a functional subject to an integral constraint.
969#[derive(Debug, Clone)]
970pub struct IsoperimetricProblem {
971    /// The integral constraint (e.g., "integral of y dx = A").
972    pub constraint: String,
973    /// The objective functional (e.g., "perimeter").
974    pub objective: String,
975}
976impl IsoperimetricProblem {
977    /// Create a new `IsoperimetricProblem`.
978    pub fn new(constraint: impl Into<String>, objective: impl Into<String>) -> Self {
979        Self {
980            constraint: constraint.into(),
981            objective: objective.into(),
982        }
983    }
984    /// Lagrange multiplier method: adjoin constraint with multiplier λ.
985    pub fn lagrange_multiplier_method(&self) -> String {
986        format!(
987            "Extremize F[y] = {} subject to G[y] = {}. \
988             Form augmented functional H[y] = F[y] - lambda * G[y] \
989             and apply Euler-Lagrange to H.",
990            self.objective, self.constraint
991        )
992    }
993}
994/// Computational data for the Lyusternik-Schnirelmann minimax procedure.
995///
996/// Stores the LS category and the minimax values c_k for k = 1, …, cat(M).
997#[allow(dead_code)]
998#[derive(Debug, Clone, Default)]
999pub struct LyusternikSchnirelmannData {
1000    /// The Lyusternik-Schnirelmann category cat(M).
1001    pub ls_category: usize,
1002    /// Minimax values c_1 ≤ c_2 ≤ … ≤ c_{cat(M)}.
1003    pub minimax_values: Vec<f64>,
1004    /// Descriptions of the corresponding critical points.
1005    pub critical_point_labels: Vec<String>,
1006}
1007impl LyusternikSchnirelmannData {
1008    /// Create a new LS data record with given category.
1009    pub fn new(ls_category: usize) -> Self {
1010        Self {
1011            ls_category,
1012            minimax_values: Vec::new(),
1013            critical_point_labels: Vec::new(),
1014        }
1015    }
1016    /// Record a minimax value and label.
1017    pub fn add_minimax_value(&mut self, value: f64, label: &str) {
1018        self.minimax_values.push(value);
1019        self.critical_point_labels.push(label.to_string());
1020    }
1021    /// Check whether all minimax values have been found (n = cat(M)).
1022    pub fn all_critical_points_found(&self) -> bool {
1023        self.minimax_values.len() >= self.ls_category
1024    }
1025    /// The LS theorem guarantees at least `ls_category` critical points.
1026    pub fn lower_bound_on_critical_points(&self) -> usize {
1027        self.ls_category
1028    }
1029    /// Minimax values are non-decreasing (the LS sequence is ordered).
1030    pub fn is_ordered(&self) -> bool {
1031        self.minimax_values.windows(2).all(|w| w[0] <= w[1] + 1e-12)
1032    }
1033    /// Return the mountain-pass value: the second minimax value c_2.
1034    pub fn mountain_pass_value(&self) -> Option<f64> {
1035        self.minimax_values.get(1).copied()
1036    }
1037}
1038/// Hamiltonian mechanics via Legendre transform of the Lagrangian.
1039#[allow(non_snake_case)]
1040#[derive(Debug, Clone)]
1041pub struct HamiltonianMechanics {
1042    /// Number of degrees of freedom (dimension of configuration space).
1043    pub n_dof: usize,
1044    /// Expression for the Hamiltonian H(q, p, t).
1045    #[allow(non_snake_case)]
1046    pub H: String,
1047}
1048impl HamiltonianMechanics {
1049    /// Create a new Hamiltonian system with `n` degrees of freedom.
1050    pub fn new(n: usize) -> Self {
1051        let coords: Vec<String> = (0..n).map(|i| format!("q_{i}")).collect();
1052        let momenta: Vec<String> = (0..n).map(|i| format!("p_{i}")).collect();
1053        let all_vars = [coords.as_slice(), momenta.as_slice()].concat().join(", ");
1054        Self {
1055            n_dof: n,
1056            H: format!("H({all_vars}, t)"),
1057        }
1058    }
1059    /// Hamilton's canonical equations: dq_i/dt = ∂H/∂p_i, dp_i/dt = -∂H/∂q_i.
1060    pub fn hamilton_equations_string(&self) -> Vec<String> {
1061        (0..self.n_dof)
1062            .flat_map(|i| {
1063                vec![
1064                    format!("dq_{i}/dt = ∂H/∂p_{i}"),
1065                    format!("dp_{i}/dt = -∂H/∂q_{i}"),
1066                ]
1067            })
1068            .collect()
1069    }
1070    /// Dimension of phase space = 2 * n_dof.
1071    pub fn phase_space_dim(&self) -> usize {
1072        2 * self.n_dof
1073    }
1074}
1075/// Hamilton-Jacobi-Bellman equation for the value function V(x, t).
1076#[derive(Debug, Clone)]
1077pub struct HamiltonJacobiBellman {
1078    /// Description of the value function V(x, t).
1079    pub value_function: String,
1080    /// Dimension of the state space.
1081    pub dimension: usize,
1082}
1083impl HamiltonJacobiBellman {
1084    /// Create a new `HamiltonJacobiBellman` object for a given state `dimension`.
1085    pub fn new(dimension: usize) -> Self {
1086        Self {
1087            value_function: format!("V : R^{dimension} x [0,T] -> R"),
1088            dimension,
1089        }
1090    }
1091    /// Verification theorem: a smooth solution V to the HJB PDE is the value function,
1092    /// and the minimizer u* = argmin_u H(x, u, ∂V/∂x) is an optimal control.
1093    pub fn verification_theorem(&self) -> String {
1094        format!(
1095            "If V : R^{dim} x [0,T] -> R is C^1 and satisfies \
1096             -∂V/∂t = min_u H(x, u, ∇_x V, t) with V(x,T) = phi(x), \
1097             then V is the optimal value function and u*(x,t) = argmin_u H is optimal.",
1098            dim = self.dimension
1099        )
1100    }
1101}
1102/// Gamma-convergence of a sequence of functionals F_n to a limit F.
1103///
1104/// F_n Gamma-converges to F iff:
1105///   (lsc) for every u_n -> u: F(u) ≤ liminf F_n(u_n),
1106///   (recovery) for every u there exists u_n -> u with F_n(u_n) -> F(u).
1107#[derive(Debug, Clone)]
1108pub struct GammaLimit {
1109    /// Name/description of the sequence F_n.
1110    pub sequence_name: String,
1111    /// Name/description of the Gamma-limit F.
1112    pub limit_name: String,
1113}
1114impl GammaLimit {
1115    /// Create a new `GammaLimit`.
1116    pub fn new(sequence_name: impl Into<String>, limit_name: impl Into<String>) -> Self {
1117        Self {
1118            sequence_name: sequence_name.into(),
1119            limit_name: limit_name.into(),
1120        }
1121    }
1122    /// The Gamma-limit equals the lower-semicontinuous envelope (relaxation) of the pointwise limit.
1123    pub fn lsc_envelope(&self) -> String {
1124        format!(
1125            "Gamma-lim F_n = lsc envelope of (pointwise lim F_n)  \
1126             [sequence: {}, limit: {}]",
1127            self.sequence_name, self.limit_name
1128        )
1129    }
1130    /// A recovery sequence always exists by definition of Gamma-convergence.
1131    pub fn recovery_sequence_exists(&self) -> bool {
1132        true
1133    }
1134}
1135/// Weak convergence of a sequence in a Sobolev space.
1136#[derive(Debug, Clone)]
1137pub struct WeakConvergence {
1138    /// Name or description of the sequence (u_n).
1139    pub sequence: String,
1140    /// Name or description of the weak limit u.
1141    pub limit: String,
1142    /// The Sobolev space in which convergence is considered.
1143    pub space: SobolevSpace,
1144}
1145impl WeakConvergence {
1146    /// Create a new `WeakConvergence` record.
1147    pub fn new(sequence: impl Into<String>, limit: impl Into<String>, space: SobolevSpace) -> Self {
1148        Self {
1149            sequence: sequence.into(),
1150            limit: limit.into(),
1151            space,
1152        }
1153    }
1154    /// Reflexive Banach spaces (p > 1) admit weakly convergent subsequences from bounded sequences.
1155    pub fn is_weakly_convergent(&self) -> bool {
1156        self.space.p > 1.0
1157    }
1158}
1159/// Pontryagin's minimum principle for optimal control.
1160#[derive(Debug, Clone)]
1161pub struct PontryaginPrinciple {
1162    /// The underlying control system.
1163    pub system: ControlSystem,
1164    /// The running cost / objective J[u] = ∫ L(x, u, t) dt.
1165    pub cost: String,
1166}
1167impl PontryaginPrinciple {
1168    /// Create a new `PontryaginPrinciple` for `system` with given `cost`.
1169    pub fn new(system: ControlSystem, cost: impl Into<String>) -> Self {
1170        Self {
1171            system,
1172            cost: cost.into(),
1173        }
1174    }
1175    /// The Pontryagin Hamiltonian H(x, u, p, t) = L(x, u, t) + p^T f(x, u, t).
1176    pub fn hamiltonian_string(&self) -> String {
1177        format!(
1178            "H(x, u, p, t) = L(x, u, t) + p^T * f(x, u, t)  \
1179             where p in R^{} is the costate (adjoint variable)",
1180            self.system.state_dim
1181        )
1182    }
1183}
1184/// A general functional mapping a function space to a field.
1185#[derive(Debug, Clone)]
1186pub struct Functional {
1187    /// Name of the functional.
1188    pub name: String,
1189    /// Description of the domain (function space).
1190    pub domain: String,
1191    /// Description of the codomain (field).
1192    pub codomain: String,
1193}
1194impl Functional {
1195    /// Create a new `Functional`.
1196    pub fn new(
1197        name: impl Into<String>,
1198        domain: impl Into<String>,
1199        codomain: impl Into<String>,
1200    ) -> Self {
1201        Self {
1202            name: name.into(),
1203            domain: domain.into(),
1204            codomain: codomain.into(),
1205        }
1206    }
1207}
1208/// Solution to the Euler-Lagrange equations for a given Lagrangian.
1209#[derive(Debug, Clone)]
1210pub struct ELSolution {
1211    /// The Lagrangian for which the E-L equations were solved.
1212    pub lagrangian: LagrangianFunction,
1213    /// Description of the solution (extremal path).
1214    pub solution: String,
1215    /// Whether this critical path is truly extremal.
1216    pub is_extremal: bool,
1217}
1218impl ELSolution {
1219    /// Construct a default `ELSolution` for a 1-DOF system.
1220    pub fn new() -> Self {
1221        Self {
1222            lagrangian: LagrangianFunction::new(1),
1223            solution: "q(t) = q_0 + (q_1 - q_0) * t".to_string(),
1224            is_extremal: true,
1225        }
1226    }
1227    /// Returns `true` if the second variation is strictly positive (Legendre + Jacobi hold).
1228    pub fn is_local_minimum(&self) -> bool {
1229        self.is_extremal
1230    }
1231    /// Returns `true` if the second variation is strictly negative.
1232    pub fn is_local_maximum(&self) -> bool {
1233        false
1234    }
1235    /// Returns `true` if the extremal is a saddle point of the functional.
1236    pub fn is_saddle(&self) -> bool {
1237        !self.is_extremal
1238    }
1239}
1240/// Legendre transform: conjugate variable p = ∂f/∂v, g(p) = sup_v (p*v - f(v)).
1241#[derive(Debug, Clone)]
1242pub struct LegendreTransform {
1243    /// The primal function f(v).
1244    pub function: String,
1245    /// The primal variable v.
1246    pub variable: String,
1247    /// The conjugate variable p = ∂f/∂v.
1248    pub conjugate: String,
1249}
1250impl LegendreTransform {
1251    /// Create a new `LegendreTransform` for f(v) with conjugate variable p.
1252    pub fn new(
1253        function: impl Into<String>,
1254        variable: impl Into<String>,
1255        conjugate: impl Into<String>,
1256    ) -> Self {
1257        Self {
1258            function: function.into(),
1259            variable: variable.into(),
1260            conjugate: conjugate.into(),
1261        }
1262    }
1263    /// The Legendre transform is an involution on convex functions: (f*)* = f.
1264    pub fn is_involution(&self) -> bool {
1265        true
1266    }
1267}
1268/// Checks the Palais-Smale condition for a sequence of functional values
1269/// and gradient norms.
1270///
1271/// A sequence satisfies (PS) if |F(u_n)| is bounded and |F'(u_n)| → 0
1272/// implies the sequence has a Cauchy subsequence (approximated here by
1273/// checking whether step sizes → 0).
1274#[allow(dead_code)]
1275#[derive(Debug, Clone, Default)]
1276pub struct PalaisSmaleChecker {
1277    /// Recorded values F(u_n).
1278    pub values: Vec<f64>,
1279    /// Recorded gradient norms |F'(u_n)|.
1280    pub gradient_norms: Vec<f64>,
1281    /// Recorded step sizes |u_{n+1} − u_n|.
1282    pub step_sizes: Vec<f64>,
1283}
1284impl PalaisSmaleChecker {
1285    /// Create an empty checker.
1286    pub fn new() -> Self {
1287        Self::default()
1288    }
1289    /// Record a new iterate with functional value, gradient norm, and step size.
1290    pub fn record(&mut self, value: f64, grad_norm: f64, step_size: f64) {
1291        self.values.push(value);
1292        self.gradient_norms.push(grad_norm);
1293        self.step_sizes.push(step_size);
1294    }
1295    /// Check whether the values are bounded: |F(u_n)| ≤ C for some C.
1296    pub fn values_bounded(&self, bound: f64) -> bool {
1297        self.values.iter().all(|v| v.abs() <= bound)
1298    }
1299    /// Check whether the gradient norms converge to 0 (last value < tol).
1300    pub fn gradients_converge_to_zero(&self, tol: f64) -> bool {
1301        self.gradient_norms
1302            .last()
1303            .map(|&g| g < tol)
1304            .unwrap_or(false)
1305    }
1306    /// Check the approximate (PS) condition: bounded values + small gradient
1307    /// AND decreasing step sizes (Cauchy-like behaviour).
1308    pub fn satisfies_approximate_ps(&self, value_bound: f64, grad_tol: f64) -> bool {
1309        if !self.values_bounded(value_bound) || !self.gradients_converge_to_zero(grad_tol) {
1310            return false;
1311        }
1312        if self.step_sizes.len() < 2 {
1313            return true;
1314        }
1315        self.step_sizes.windows(2).all(|w| w[1] <= w[0] + 1e-12)
1316    }
1317    /// Return the minimum gradient norm recorded.
1318    pub fn min_gradient_norm(&self) -> f64 {
1319        self.gradient_norms
1320            .iter()
1321            .cloned()
1322            .fold(f64::INFINITY, f64::min)
1323    }
1324}
1325/// Lagrangian mechanics for a system with `n_dof` degrees of freedom.
1326#[derive(Debug, Clone)]
1327pub struct LagrangianMechanics {
1328    /// Number of degrees of freedom.
1329    pub n_dof: usize,
1330    /// List of holonomic constraints (as strings).
1331    pub constraints: Vec<String>,
1332}
1333impl LagrangianMechanics {
1334    /// Create a new unconstrained Lagrangian system with `n` degrees of freedom.
1335    pub fn new(n: usize) -> Self {
1336        Self {
1337            n_dof: n,
1338            constraints: vec![],
1339        }
1340    }
1341    /// Kinetic energy T = (1/2) Σ m_i q̇_i² in string form.
1342    pub fn kinetic_energy_string(&self) -> String {
1343        let terms: Vec<String> = (0..self.n_dof)
1344            .map(|i| format!("m_{i} * q̇_{i}^2"))
1345            .collect();
1346        format!("T = (1/2) * ({})", terms.join(" + "))
1347    }
1348    /// Potential energy V = V(q_1, ..., q_n) in string form.
1349    pub fn potential_energy_string(&self) -> String {
1350        let args: Vec<String> = (0..self.n_dof).map(|i| format!("q_{i}")).collect();
1351        format!("V = V({})", args.join(", "))
1352    }
1353}