Skip to main content

alkahest_cas/diffalg/
mod.rs

1//! V2-13 — Differential algebra / Rosenfeld–Gröbner-style differential elimination.
2//!
3//! Given a polynomial DAE in implicit form `g_i(t, y, y') = 0`, prolongation
4//! appends formal time derivatives `D^k(g_i)` while tracking the derivative
5//! state layout (same structural rule as [`crate::dae::pantelides`]).  After
6//! each prolongation step, an ordinary Gröbner basis over ℚ captures the
7//! algebraic constraints among the jet variables.  Inconsistent systems
8//! yield the unit ideal (basis containing a non-zero constant).
9//!
10//! This is a **fragment** of the full Rosenfeld–Gröbner / regular differential
11//! decomposition (no multi-branch saturation over initials here); it suffices
12//! for consistency checking and complements Pantelides when the structural
13//! index is high.
14//!
15//! References:
16//! - Boulier et al., *Rosenfeld–Gröbner algorithm* (differential elimination).
17//! - Hubert, *Differential algebra for comptroller generation*.
18
19use crate::dae::{
20    differentiate_equation, extend_dae_for_derivative_symbols, pantelides, DaeError,
21    PantelidesResult, DAE,
22};
23use crate::errors::AlkahestError;
24use crate::kernel::{ExprData, ExprId, ExprPool};
25use crate::poly::groebner::{
26    GbPoly, GroebnerBasis, MonomialOrder, ParamGbPoly, ParamGroebnerBasis, ParamGroebnerError,
27};
28use crate::solver::SolverError;
29use crate::solver::{expr_to_gbpoly, expr_to_param_gbpoly};
30use std::collections::{BTreeMap, HashSet};
31use std::fmt;
32
33/// Default prolongation depth (formal time derivatives chained per equation).
34const DEFAULT_MAX_PROLONG_ROUNDS: usize = 8;
35
36/// Ordering data for differential indeterminates: `vars[i]` maps to exponent
37/// index `i` of [`GbPoly`] used in Gröbner steps.
38#[derive(Clone, Debug)]
39pub struct DifferentialRanking {
40    pub vars: Vec<ExprId>,
41}
42
43/// Algebraic presentation of a finitely generated differential ideal (dense
44/// ℚ-polynomial generators in a fixed jet basis).
45#[derive(Clone, Debug)]
46pub struct DifferentialIdeal {
47    pub generators: Vec<GbPoly>,
48}
49
50/// Ordinary polynomial ring context for a fixed independent variable and ranked jets.
51#[derive(Clone, Debug)]
52pub struct DifferentialRing {
53    pub time: ExprId,
54    pub ranked_indeterminates: Vec<ExprId>,
55}
56
57/// One component produced by differential elimination — here the algebraic
58/// Gröbner basis of a saturated ordinary ideal at the end of prolongation.
59#[derive(Clone, Debug)]
60pub struct RegularDifferentialChain {
61    pub basis: GroebnerBasis,
62}
63
64/// Result of [`rosenfeld_groebner`] (single coherent component in this build).
65#[derive(Clone, Debug)]
66pub struct RosenfeldGroebnerResult {
67    /// `false` iff the unit ideal was encountered (no common jet solution over ℚ).
68    pub consistent: bool,
69    /// Non-empty when [`Self::consistent`] is true (one entry in this implementation).
70    pub chains: Vec<RegularDifferentialChain>,
71    /// [`DAE`] state after prolongation (extra derivative jets may be present).
72    pub working_dae: DAE,
73    /// Final Gröbner basis when consistent.
74    pub final_basis: Option<GroebnerBasis>,
75    /// Number of prolongation rounds that added new relations.
76    pub prolongation_rounds: usize,
77    /// `true` if we stopped only because the prolongation budget was reached
78    /// (the differential chain need not be saturated).
79    pub truncated: bool,
80}
81
82/// Outcome of [`dae_index_reduce`]: Pantelides when it succeeds, otherwise a
83/// Rosenfeld–Gröbner consistency pass when Pantelides hits its index cap.
84#[derive(Clone, Debug)]
85pub enum DaeIndexReduction {
86    Pantelides(PantelidesResult),
87    Rosenfeld(RosenfeldGroebnerResult),
88}
89
90/// Errors from differential-algebra operations.
91#[derive(Debug, Clone)]
92pub enum DiffAlgError {
93    DiffError(String),
94    NotPolynomial(String),
95    EmptySystem,
96}
97
98impl fmt::Display for DiffAlgError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            DiffAlgError::DiffError(s) => write!(f, "differentiation error: {s}"),
102            DiffAlgError::NotPolynomial(s) => write!(f, "not a polynomial: {s}"),
103            DiffAlgError::EmptySystem => write!(f, "empty equation system"),
104        }
105    }
106}
107
108impl std::error::Error for DiffAlgError {}
109
110impl AlkahestError for DiffAlgError {
111    fn code(&self) -> &'static str {
112        match self {
113            DiffAlgError::DiffError(_) => "E-DIFFALG-001",
114            DiffAlgError::NotPolynomial(_) => "E-DIFFALG-002",
115            DiffAlgError::EmptySystem => "E-DIFFALG-003",
116        }
117    }
118
119    fn remediation(&self) -> Option<&'static str> {
120        match self {
121            DiffAlgError::DiffError(_) => {
122                Some("ensure the DAE is polynomial in its state and derivative symbols")
123            }
124            DiffAlgError::NotPolynomial(_) => {
125                Some("declare all jet variables and parameters; remove transcendental functions")
126            }
127            DiffAlgError::EmptySystem => Some("pass at least one implicit equation"),
128        }
129    }
130}
131
132fn solver_err_to_diffalg(e: SolverError) -> DiffAlgError {
133    DiffAlgError::NotPolynomial(e.to_string())
134}
135
136fn is_unit_ideal_gb(gb: &GroebnerBasis) -> bool {
137    gb.generators().iter().any(|g| {
138        g.terms.len() == 1
139            && g.terms
140                .keys()
141                .next()
142                .is_some_and(|e| e.iter().all(|&x| x == 0))
143            && g.terms.values().next().is_some_and(|c| *c != 0)
144    })
145}
146
147fn pad_gbpoly(p: &GbPoly, new_n: usize) -> GbPoly {
148    if new_n == p.n_vars {
149        return p.clone();
150    }
151    assert!(new_n > p.n_vars);
152    let pad = new_n - p.n_vars;
153    let mut terms = BTreeMap::new();
154    for (e, c) in &p.terms {
155        let mut ne = e.clone();
156        ne.extend(std::iter::repeat(0u32).take(pad));
157        terms.insert(ne, c.clone());
158    }
159    GbPoly {
160        terms,
161        n_vars: new_n,
162    }
163}
164
165fn children(expr: ExprId, pool: &ExprPool) -> Vec<ExprId> {
166    pool.with(expr, |data| match data {
167        ExprData::Add(args) | ExprData::Mul(args) | ExprData::Func { args, .. } => args.clone(),
168        ExprData::Pow { base, exp } => vec![*base, *exp],
169        ExprData::BigO(inner) => vec![*inner],
170        _ => vec![],
171    })
172}
173
174fn collect_symbols(
175    expr: ExprId,
176    pool: &ExprPool,
177    seen: &mut HashSet<ExprId>,
178    out: &mut Vec<ExprId>,
179) {
180    let is_sym = pool.with(expr, |d| matches!(d, ExprData::Symbol { .. }));
181    if is_sym && seen.insert(expr) {
182        out.push(expr);
183    }
184    for c in children(expr, pool) {
185        collect_symbols(c, pool, seen, out);
186    }
187}
188
189fn vars_for_dae(dae: &DAE, scratch: &[ExprId], pool: &ExprPool) -> Vec<ExprId> {
190    let mut seen = HashSet::new();
191    let mut out = Vec::new();
192    let mut push = |id: ExprId| {
193        if seen.insert(id) {
194            out.push(id);
195        }
196    };
197    push(dae.time_var);
198    for i in 0..dae.variables.len() {
199        push(dae.variables[i]);
200        push(dae.derivatives[i]);
201    }
202    for &root in scratch {
203        collect_symbols(root, pool, &mut seen, &mut out);
204    }
205    out
206}
207
208/// Append every symbol of `fresh` that `vars` does not already name.
209///
210/// Prolongation pads existing polynomials by extending their exponent vectors,
211/// which is only meaningful if slot `i` keeps naming the same symbol from round
212/// to round.  Recomputing the ranking from scratch does **not** guarantee that:
213/// a jet reached only through the previous round's equations drops out of the
214/// next round's scratch set, every later slot shifts down by one, and the
215/// padded polynomials silently start asserting relations about other variables.
216/// Merging instead of replacing keeps the ranking append-only, which is what
217/// [`pad_gbpoly`] assumes.
218fn merge_vars(vars: &mut Vec<ExprId>, fresh: Vec<ExprId>) {
219    for v in fresh {
220        if !vars.contains(&v) {
221            vars.push(v);
222        }
223    }
224}
225
226fn polys_from_equations(
227    eqs: &[ExprId],
228    vars: &[ExprId],
229    pool: &ExprPool,
230) -> Result<Vec<GbPoly>, DiffAlgError> {
231    eqs.iter()
232        .map(|&eq| expr_to_gbpoly(eq, vars, pool).map_err(solver_err_to_diffalg))
233        .collect()
234}
235
236/// Algebraic-only slice: Gröbner basis of the generators; empty `Vec` if the
237/// ideal is `[1]`.
238pub fn rosenfeld_groebner_algebraic(
239    gens: Vec<GbPoly>,
240    order: MonomialOrder,
241) -> Result<Vec<RegularDifferentialChain>, DiffAlgError> {
242    if gens.is_empty() {
243        return Err(DiffAlgError::EmptySystem);
244    }
245    let gb = GroebnerBasis::compute(gens, order);
246    if is_unit_ideal_gb(&gb) {
247        return Ok(vec![]);
248    }
249    Ok(vec![RegularDifferentialChain { basis: gb }])
250}
251
252/// Rosenfeld-style prolongation + Gröbner stabilization for a polynomial DAE.
253///
254/// `max_prolong_rounds` bounds how many prolongation steps are attempted; if the
255/// chain has not saturated, [`RosenfeldGroebnerResult::truncated`] is set — typical
256/// nonlinear ODE jets do not stabilize in finitely many algebraic steps.
257pub fn rosenfeld_groebner_with_options(
258    dae: &DAE,
259    pool: &ExprPool,
260    order: MonomialOrder,
261    max_prolong_rounds: usize,
262) -> Result<RosenfeldGroebnerResult, DiffAlgError> {
263    rosenfeld_groebner_ranked(dae, pool, order, max_prolong_rounds).map(|(result, _)| result)
264}
265
266/// [`rosenfeld_groebner_with_options`] plus the jet [`DifferentialRanking`] that
267/// indexes the exponent vectors of [`RosenfeldGroebnerResult::final_basis`].
268///
269/// The elimination result is unreadable without this: a [`GbPoly`] stores only
270/// exponent vectors, so `ranking.vars[i]` is what exponent slot `i` refers to.
271/// Pair it with [`crate::solver::gbpoly_to_expr`] to recover the input–output
272/// equations as [`ExprId`]s.
273pub fn rosenfeld_groebner_ranked(
274    dae: &DAE,
275    pool: &ExprPool,
276    order: MonomialOrder,
277    max_prolong_rounds: usize,
278) -> Result<(RosenfeldGroebnerResult, DifferentialRanking), DiffAlgError> {
279    if dae.equations.is_empty() {
280        return Err(DiffAlgError::EmptySystem);
281    }
282
283    let source_eqs = dae.equations.clone();
284    let mut work = dae.clone();
285    let mut scratch: Vec<ExprId> = source_eqs.clone();
286    let mut vars = vars_for_dae(&work, &scratch, pool);
287    let mut active = polys_from_equations(&work.equations, &vars, pool)?;
288
289    let mut prolong_exprs = source_eqs.clone();
290    let mut prolongation_rounds: usize = 0;
291
292    for round in 0..max_prolong_rounds {
293        let gb = GroebnerBasis::compute(active.clone(), order);
294        if is_unit_ideal_gb(&gb) {
295            return Ok((
296                RosenfeldGroebnerResult {
297                    consistent: false,
298                    chains: vec![],
299                    working_dae: work,
300                    final_basis: None,
301                    prolongation_rounds,
302                    truncated: false,
303                },
304                DifferentialRanking { vars },
305            ));
306        }
307
308        let mut next_prolong = Vec::with_capacity(prolong_exprs.len());
309        for &eq in &prolong_exprs {
310            let d_eq =
311                differentiate_equation(eq, &work.variables, &work.derivatives, work.time_var, pool)
312                    .map_err(|e| DiffAlgError::DiffError(e.to_string()))?;
313            extend_dae_for_derivative_symbols(&mut work, d_eq, pool);
314            next_prolong.push(d_eq);
315        }
316        prolong_exprs = next_prolong;
317        scratch = source_eqs
318            .iter()
319            .copied()
320            .chain(prolong_exprs.iter().copied())
321            .collect();
322        merge_vars(&mut vars, vars_for_dae(&work, &scratch, pool));
323        let n = vars.len();
324        for p in &mut active {
325            *p = pad_gbpoly(p, n);
326        }
327
328        let gb_check = GroebnerBasis::compute(active.clone(), order);
329        let mut to_add: Vec<GbPoly> = Vec::new();
330        for &d_eq in &prolong_exprs {
331            let p = expr_to_gbpoly(d_eq, &vars, pool).map_err(solver_err_to_diffalg)?;
332            if !gb_check.contains(&p) {
333                to_add.push(p);
334            }
335        }
336
337        if to_add.is_empty() {
338            let final_basis = GroebnerBasis::compute(active, order);
339            let consistent = !is_unit_ideal_gb(&final_basis);
340            let chains = if consistent {
341                vec![RegularDifferentialChain {
342                    basis: final_basis.clone(),
343                }]
344            } else {
345                vec![]
346            };
347            return Ok((
348                RosenfeldGroebnerResult {
349                    consistent,
350                    chains,
351                    working_dae: work,
352                    final_basis: if consistent { Some(final_basis) } else { None },
353                    prolongation_rounds,
354                    truncated: false,
355                },
356                DifferentialRanking { vars },
357            ));
358        }
359
360        active.extend(to_add);
361        prolongation_rounds += 1;
362
363        if round + 1 == max_prolong_rounds {
364            let final_basis = GroebnerBasis::compute(active, order);
365            let consistent = !is_unit_ideal_gb(&final_basis);
366            let chains = if consistent {
367                vec![RegularDifferentialChain {
368                    basis: final_basis.clone(),
369                }]
370            } else {
371                vec![]
372            };
373            return Ok((
374                RosenfeldGroebnerResult {
375                    consistent,
376                    chains,
377                    working_dae: work,
378                    final_basis: if consistent { Some(final_basis) } else { None },
379                    prolongation_rounds,
380                    truncated: true,
381                },
382                DifferentialRanking { vars },
383            ));
384        }
385    }
386
387    let final_basis = GroebnerBasis::compute(active, order);
388    let consistent = !is_unit_ideal_gb(&final_basis);
389    Ok((
390        RosenfeldGroebnerResult {
391            consistent,
392            chains: if consistent {
393                vec![RegularDifferentialChain {
394                    basis: final_basis.clone(),
395                }]
396            } else {
397                vec![]
398            },
399            working_dae: work,
400            final_basis: if consistent { Some(final_basis) } else { None },
401            prolongation_rounds,
402            truncated: true,
403        },
404        DifferentialRanking { vars },
405    ))
406}
407
408// ---------------------------------------------------------------------------
409// M9 × V2-13 — differential elimination with the parameters in Q(params)
410// ---------------------------------------------------------------------------
411
412/// Knobs for [`rosenfeld_groebner_parametric`].
413#[derive(Clone, Copy, Debug)]
414pub struct ParametricProlongOpts<'a> {
415    /// Monomial order for each round's basis.  `Lex` with the variables to
416    /// eliminate ordered first is what elimination needs.
417    pub order: MonomialOrder,
418    /// Prolongation budget — the number of formal time derivatives taken.
419    pub max_prolong_rounds: usize,
420    /// The variables the caller intends to eliminate, e.g. the unobserved
421    /// states of an ODE model.  Their whole jet chain is eliminated with them:
422    /// naming `x` also names `dx/dt`, `d2x/dt2`, … as they appear.
423    ///
424    /// Empty disables both the informativeness check and [`Self::minimal`].
425    pub eliminate: &'a [ExprId],
426    /// Stop at the **first** prolongation round whose elimination ideal is
427    /// non-trivial, instead of prolonging to the budget.
428    ///
429    /// See [`ParametricRosenfeldResult::minimal_prolongation_rounds`] for the
430    /// scope of "first informative" — it is not a guarantee of minimality.
431    pub minimal: bool,
432}
433
434impl Default for ParametricProlongOpts<'_> {
435    fn default() -> Self {
436        ParametricProlongOpts {
437            order: MonomialOrder::Lex,
438            max_prolong_rounds: DEFAULT_MAX_PROLONG_ROUNDS,
439            eliminate: &[],
440            minimal: false,
441        }
442    }
443}
444
445/// Result of [`rosenfeld_groebner_parametric`].
446#[derive(Clone, Debug)]
447pub struct ParametricRosenfeldResult {
448    /// `false` iff the unit ideal was reached over `Q(params)`.
449    pub consistent: bool,
450    /// `true` if prolongation stopped on the budget, or on `minimal`, rather
451    /// than because differentiating stopped adding relations.  A truncated
452    /// basis is a sound set of consequences but need not be complete.
453    pub truncated: bool,
454    /// Number of prolongation rounds that contributed new relations.
455    pub prolongation_rounds: usize,
456    /// The prolonged [`DAE`]: the input plus every jet introduced.
457    pub working_dae: DAE,
458    /// The saturated basis over `Q(params)`, or `None` when inconsistent.
459    pub final_basis: Option<ParamGroebnerBasis>,
460    /// The lowest round count at which the elimination ideal with respect to
461    /// [`ParametricProlongOpts::eliminate`] was non-empty, or `None` when no
462    /// round was informative or no `eliminate` list was given.
463    ///
464    /// **Scope.** This is "the first round at which eliminating those variables
465    /// leaves a generator", not a theorem about the differential ideal.  For a
466    /// single-output model it coincides with the jet order the input–output
467    /// relation needs; **for multi-output models the criterion is known to be
468    /// wrong**, because one output can become informative several rounds before
469    /// the others and the truncated basis then misses their relations.  Treat
470    /// it as a cost signal, not a certificate.
471    ///
472    /// It matters because the cost is not gentle: on the SIR model one extra
473    /// prolongation past the informative round takes the elimination from a
474    /// single 4-term generator to thirteen generators of up to 233 terms with
475    /// 30-digit rational-function coefficients — four orders of magnitude of
476    /// time, for the same relation.
477    pub minimal_prolongation_rounds: Option<usize>,
478}
479
480fn param_err_to_diffalg(e: ParamGroebnerError) -> DiffAlgError {
481    DiffAlgError::NotPolynomial(e.to_string())
482}
483
484fn is_unit_ideal_param(gb: &ParamGroebnerBasis) -> bool {
485    gb.generators().iter().any(|g| {
486        g.terms.len() == 1
487            && g.terms
488                .keys()
489                .next()
490                .is_some_and(|e| e.iter().all(|&x| x == 0))
491    })
492}
493
494/// The exponent slots of `vars` naming `roots` or any jet descended from one.
495///
496/// `dae.variables[i]` differentiates to `dae.derivatives[i]`, so following that
497/// map to a fixed point turns "eliminate `x`" into "eliminate `x`, `dx/dt`,
498/// `d2x/dt2`, …" — which is what a caller eliminating a state means.
499fn jet_closure_slots(dae: &DAE, vars: &[ExprId], roots: &[ExprId]) -> Vec<usize> {
500    let mut closed: HashSet<ExprId> = roots.iter().copied().collect();
501    loop {
502        let mut grew = false;
503        for (i, v) in dae.variables.iter().enumerate() {
504            if closed.contains(v) {
505                if let Some(&d) = dae.derivatives.get(i) {
506                    grew |= closed.insert(d);
507                }
508            }
509        }
510        if !grew {
511            break;
512        }
513    }
514    vars.iter()
515        .enumerate()
516        .filter(|(_, v)| closed.contains(v))
517        .map(|(i, _)| i)
518        .collect()
519}
520
521/// True when at least one generator is free of every slot in `slots` — i.e. the
522/// elimination ideal is non-trivial.
523fn param_elimination_is_informative(gb: &ParamGroebnerBasis, slots: &[usize]) -> bool {
524    gb.generators().iter().any(|g| {
525        !g.terms
526            .keys()
527            .any(|e| slots.iter().any(|&i| e.get(i).copied().unwrap_or(0) > 0))
528    })
529}
530
531/// The jet chain `v, dv, d²v, …` of a state, `depth` derivatives deep, using
532/// the same `d{name}/dt` naming convention prolongation itself uses.
533fn jet_chain(v: ExprId, dv: ExprId, depth: usize, pool: &ExprPool) -> Vec<ExprId> {
534    let mut out = vec![v, dv];
535    let mut cur = dv;
536    for _ in 0..depth {
537        let name = pool.with(cur, |d| match d {
538            ExprData::Symbol { name, .. } => format!("d{name}/dt"),
539            _ => "d?/dt".to_string(),
540        });
541        cur = pool.symbol(&name, crate::kernel::Domain::Real);
542        out.push(cur);
543    }
544    out
545}
546
547/// A ranking with the whole jet tower laid out up front, eliminated states
548/// first.
549///
550/// Two things need this.  Elimination by generator filtering is only valid
551/// under a lex order that ranks the eliminated variables *above* the rest, and
552/// [`vars_for_dae`] interleaves states with outputs instead.  And the ranking
553/// has to be append-only across rounds for [`pad_param_gbpoly`] to mean
554/// anything, which it cannot be if new jets of an eliminated state keep having
555/// to be inserted in front of the outputs.  Laying the tower out to the
556/// prolongation depth settles both; the jets that never get used are unused
557/// variables in the ring, which cost nothing but a slot.
558fn ranked_jet_vars(
559    dae: &DAE,
560    eliminate: &[ExprId],
561    params: &[ExprId],
562    depth: usize,
563    pool: &ExprPool,
564) -> Vec<ExprId> {
565    let mut elim_first: Vec<ExprId> = Vec::new();
566    let mut rest: Vec<ExprId> = Vec::new();
567    for (i, &v) in dae.variables.iter().enumerate() {
568        let Some(&dv) = dae.derivatives.get(i) else {
569            continue;
570        };
571        let chain = jet_chain(v, dv, depth, pool);
572        if eliminate.contains(&v) {
573            elim_first.extend(chain);
574        } else {
575            rest.extend(chain);
576        }
577    }
578    let mut out: Vec<ExprId> = vec![dae.time_var];
579    for v in elim_first.into_iter().chain(rest) {
580        if !params.contains(&v) && !out.contains(&v) {
581            out.push(v);
582        }
583    }
584    out
585}
586
587fn param_vars_for_dae(
588    dae: &DAE,
589    scratch: &[ExprId],
590    params: &[ExprId],
591    pool: &ExprPool,
592) -> Vec<ExprId> {
593    vars_for_dae(dae, scratch, pool)
594        .into_iter()
595        .filter(|v| !params.contains(v))
596        .collect()
597}
598
599fn pad_param_gbpoly(p: &ParamGbPoly, new_n: usize) -> ParamGbPoly {
600    if new_n == p.n_vars {
601        return p.clone();
602    }
603    assert!(new_n > p.n_vars);
604    let pad = new_n - p.n_vars;
605    ParamGbPoly {
606        terms: p
607            .terms
608            .iter()
609            .map(|(e, c)| {
610                let mut e = e.clone();
611                e.extend(std::iter::repeat(0u32).take(pad));
612                (e, c.clone())
613            })
614            .collect(),
615        n_vars: new_n,
616        n_params: p.n_params,
617    }
618}
619
620/// Rosenfeld-style prolongation with `params` in the **coefficient field**
621/// `Q(params)` rather than as extra ring variables (M9 × V2-13).
622///
623/// [`rosenfeld_groebner_ranked`] puts every free symbol in the ring, so a model
624/// parameter enlarges the monomial order, the pair schedule and the staircase.
625/// Here the parameters are moved into the coefficients, which is the difference
626/// between eliminating states from `Q[states, jets, params]` and from
627/// `Q(params)[states, jets]` — the computation the input–output relations of an
628/// ODE model actually need.
629///
630/// The returned basis is **generic** in the parameters, exactly as
631/// [`ParamGroebnerBasis`] describes: read
632/// [`ParamGroebnerBasis::conditions`] for the hypotheses it used.
633///
634/// `params` must be disjoint from the DAE's variables, derivatives and time
635/// variable; a parameter listed there is dropped from the ring, so it must not
636/// be one of the unknowns.
637pub fn rosenfeld_groebner_parametric(
638    dae: &DAE,
639    pool: &ExprPool,
640    params: &[ExprId],
641    opts: ParametricProlongOpts<'_>,
642) -> Result<(ParametricRosenfeldResult, DifferentialRanking), DiffAlgError> {
643    if dae.equations.is_empty() {
644        return Err(DiffAlgError::EmptySystem);
645    }
646
647    let source_eqs = dae.equations.clone();
648    let mut work = dae.clone();
649    let mut scratch: Vec<ExprId> = source_eqs.clone();
650    let mut vars = if opts.eliminate.is_empty() {
651        param_vars_for_dae(&work, &scratch, params, pool)
652    } else {
653        let mut v = ranked_jet_vars(
654            &work,
655            opts.eliminate,
656            params,
657            opts.max_prolong_rounds + 1,
658            pool,
659        );
660        merge_vars(&mut v, param_vars_for_dae(&work, &scratch, params, pool));
661        v
662    };
663    let mut active: Vec<ParamGbPoly> = work
664        .equations
665        .iter()
666        .map(|&eq| expr_to_param_gbpoly(eq, &vars, params, pool).map_err(solver_err_to_diffalg))
667        .collect::<Result<_, _>>()?;
668
669    let mut prolong_exprs = source_eqs.clone();
670    let mut minimal_prolongation_rounds: Option<usize> = None;
671
672    // The budget counts prolongations, so `max + 1` bases get computed: one for
673    // the unprolonged system and one after each round.
674    for round in 0..=opts.max_prolong_rounds {
675        let gb = ParamGroebnerBasis::compute(active.clone(), opts.order)
676            .map_err(param_err_to_diffalg)?;
677
678        if is_unit_ideal_param(&gb) {
679            return Ok((
680                ParametricRosenfeldResult {
681                    consistent: false,
682                    truncated: false,
683                    prolongation_rounds: round,
684                    working_dae: work,
685                    final_basis: None,
686                    minimal_prolongation_rounds,
687                },
688                DifferentialRanking { vars },
689            ));
690        }
691
692        if !opts.eliminate.is_empty() {
693            let slots = jet_closure_slots(&work, &vars, opts.eliminate);
694            if minimal_prolongation_rounds.is_none()
695                && param_elimination_is_informative(&gb, &slots)
696            {
697                minimal_prolongation_rounds = Some(round);
698                if opts.minimal {
699                    return Ok((
700                        ParametricRosenfeldResult {
701                            consistent: true,
702                            truncated: true,
703                            prolongation_rounds: round,
704                            working_dae: work,
705                            final_basis: Some(gb),
706                            minimal_prolongation_rounds,
707                        },
708                        DifferentialRanking { vars },
709                    ));
710                }
711            }
712        }
713
714        if round == opts.max_prolong_rounds {
715            return Ok((
716                ParametricRosenfeldResult {
717                    consistent: true,
718                    truncated: true,
719                    prolongation_rounds: round,
720                    working_dae: work,
721                    final_basis: Some(gb),
722                    minimal_prolongation_rounds,
723                },
724                DifferentialRanking { vars },
725            ));
726        }
727
728        let mut next_prolong = Vec::with_capacity(prolong_exprs.len());
729        for &eq in &prolong_exprs {
730            let d_eq =
731                differentiate_equation(eq, &work.variables, &work.derivatives, work.time_var, pool)
732                    .map_err(|e| DiffAlgError::DiffError(e.to_string()))?;
733            extend_dae_for_derivative_symbols(&mut work, d_eq, pool);
734            next_prolong.push(d_eq);
735        }
736        prolong_exprs = next_prolong;
737        scratch = source_eqs
738            .iter()
739            .copied()
740            .chain(prolong_exprs.iter().copied())
741            .collect();
742        let old_n = vars.len();
743        merge_vars(&mut vars, param_vars_for_dae(&work, &scratch, params, pool));
744        let n = vars.len();
745        for p in &mut active {
746            *p = pad_param_gbpoly(p, n);
747        }
748        // The previous round's basis is still a basis over the wider ring, so
749        // the new relations can be tested against it without recomputing.
750        let gb_check = gb.extend_vars(n - old_n);
751
752        let mut to_add: Vec<ParamGbPoly> = Vec::new();
753        for &d_eq in &prolong_exprs {
754            let p =
755                expr_to_param_gbpoly(d_eq, &vars, params, pool).map_err(solver_err_to_diffalg)?;
756            if !gb_check.contains(&p) {
757                to_add.push(p);
758            }
759        }
760
761        if to_add.is_empty() {
762            // Saturated: differentiating adds nothing the ideal did not have.
763            return Ok((
764                ParametricRosenfeldResult {
765                    consistent: true,
766                    truncated: false,
767                    prolongation_rounds: round,
768                    working_dae: work,
769                    final_basis: Some(gb_check),
770                    minimal_prolongation_rounds,
771                },
772                DifferentialRanking { vars },
773            ));
774        }
775
776        active.extend(to_add);
777    }
778
779    unreachable!("the `round == max_prolong_rounds` arm returns")
780}
781
782/// Calls [`rosenfeld_groebner_with_options`] with the default maximum prolongation rounds.
783pub fn rosenfeld_groebner(
784    dae: &DAE,
785    pool: &ExprPool,
786    order: MonomialOrder,
787) -> Result<RosenfeldGroebnerResult, DiffAlgError> {
788    rosenfeld_groebner_with_options(dae, pool, order, DEFAULT_MAX_PROLONG_ROUNDS)
789}
790
791/// Try Pantelides; on [`DaeError::IndexTooHigh`], run [`rosenfeld_groebner`].
792pub fn dae_index_reduce(
793    dae: &DAE,
794    pool: &ExprPool,
795    order: MonomialOrder,
796) -> Result<DaeIndexReduction, DaeError> {
797    dae_index_reduce_ranked(dae, pool, order).map(|(r, _)| r)
798}
799
800/// [`dae_index_reduce`] plus the jet [`DifferentialRanking`] for the Gröbner
801/// fallback — `None` when Pantelides succeeded and no basis was built.
802pub fn dae_index_reduce_ranked(
803    dae: &DAE,
804    pool: &ExprPool,
805    order: MonomialOrder,
806) -> Result<(DaeIndexReduction, Option<DifferentialRanking>), DaeError> {
807    match pantelides(dae, pool) {
808        Ok(p) => Ok((DaeIndexReduction::Pantelides(p), None)),
809        Err(DaeError::IndexTooHigh) => {
810            let (r, ranking) =
811                rosenfeld_groebner_ranked(dae, pool, order, DEFAULT_MAX_PROLONG_ROUNDS).map_err(
812                    |e| match e {
813                        DiffAlgError::DiffError(s) | DiffAlgError::NotPolynomial(s) => {
814                            DaeError::DiffError(s)
815                        }
816                        DiffAlgError::EmptySystem => DaeError::StructurallyInconsistent,
817                    },
818                )?;
819            Ok((DaeIndexReduction::Rosenfeld(r), Some(ranking)))
820        }
821        Err(e) => Err(e),
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::kernel::Domain;
829
830    fn pool() -> ExprPool {
831        ExprPool::new()
832    }
833
834    #[test]
835    fn algebraic_inconsistent_unit_ideal() {
836        let p = pool();
837        let _x = p.symbol("x", Domain::Real);
838        let one_p = GbPoly::constant(rug::Rational::from(1), 1);
839        let gx = GbPoly::monomial(vec![1], rug::Rational::from(1));
840        let f = gx.add(&one_p); // x+1
841        let g = gx.sub(&one_p); // x-1
842        let chains = rosenfeld_groebner_algebraic(vec![f, g], MonomialOrder::Lex).unwrap();
843        assert!(chains.is_empty());
844    }
845
846    #[test]
847    fn lotka_volterra_first_order_consistent() {
848        let p = pool();
849        let t = p.symbol("t", Domain::Real);
850        let x = p.symbol("x", Domain::Real);
851        let y = p.symbol("y", Domain::Real);
852        let dx = p.symbol("dx/dt", Domain::Real);
853        let dy = p.symbol("dy/dt", Domain::Real);
854        // x' = x - x*y, y' = x*y - y  (coefficients 1)
855        let eq1 = p.add(vec![dx, p.mul(vec![p.integer(-1), x]), p.mul(vec![x, y])]);
856        let eq2 = p.add(vec![dy, p.mul(vec![p.integer(-1), x, y]), y]);
857        let dae = DAE::new(vec![eq1, eq2], vec![x, y], vec![dx, dy], t);
858        // Budget 0 = only test the algebraic consistency of the declared first-order ideal (no prolongation).
859        let r = rosenfeld_groebner_with_options(&dae, &p, MonomialOrder::GRevLex, 0).unwrap();
860        assert!(r.consistent && r.final_basis.is_some());
861        assert!(r.truncated);
862    }
863
864    #[test]
865    fn contradictory_linear_equations_inconsistent() {
866        let p = pool();
867        let t = p.symbol("t", Domain::Real);
868        let y = p.symbol("y", Domain::Real);
869        let dy = p.symbol("dy/dt", Domain::Real);
870        // dy - y = 0  and  dy - y - 1 = 0  → 1 ∈ ideal
871        let eq1 = p.add(vec![dy, p.mul(vec![p.integer(-1), y])]);
872        let eq2 = p.add(vec![dy, p.mul(vec![p.integer(-1), y]), p.integer(-1)]);
873        let dae = DAE::new(vec![eq1, eq2], vec![y], vec![dy], t);
874        let r = rosenfeld_groebner(&dae, &p, MonomialOrder::Lex).unwrap();
875        assert!(!r.consistent);
876    }
877
878    /// Prolongation used to emit relations the system does not imply.
879    ///
880    /// Two independent causes, both visible on `R' = I, I' = -I` after two
881    /// rounds:
882    ///
883    /// * a state whose derivative had already been promoted got its
884    ///   contribution counted twice — once from its own `(I, I')` pair and once
885    ///   from the `(I', I'')` pair the previous round added; and
886    /// * a jet that was *not* promoted got its contribution dropped entirely,
887    ///   because the next differentiation treated it as a constant.  `I''`
888    ///   reaches the equations through `R`'s chain without `R'` ever appearing
889    ///   in one, so `R'` never became a state and the chain stopped there.
890    ///
891    /// The first inflates a coefficient, the second deletes a term; together
892    /// they collapsed the second prolongation of `R' = I` into `-2·I'' = 0`,
893    /// which forces `I = 0` — a relation about a decaying exponential that is
894    /// simply false.
895    #[test]
896    fn prolongation_does_not_invent_relations() {
897        let p = pool();
898        let t = p.symbol("t", Domain::Real);
899        let i = p.symbol("I", Domain::Real);
900        let r_ = p.symbol("R", Domain::Real);
901        let di = p.symbol("dI/dt", Domain::Real);
902        let dr = p.symbol("dR/dt", Domain::Real);
903        // R' = I, I' = -I.
904        let eq1 = p.add(vec![dr, p.mul(vec![p.integer(-1), i])]);
905        let eq2 = p.add(vec![di, i]);
906        let dae = DAE::new(vec![eq1, eq2], vec![r_, i], vec![dr, di], t);
907
908        let (res, ranking) = rosenfeld_groebner_ranked(&dae, &p, MonomialOrder::Lex, 2).unwrap();
909        assert!(res.consistent);
910        let gb = res.final_basis.expect("consistent");
911
912        // The genuine consequence.
913        let d2i = p.symbol("ddI/dt/dt", Domain::Real);
914        let truth = p.add(vec![d2i, p.mul(vec![p.integer(-1), i])]);
915        let truth_poly = expr_to_gbpoly(truth, &ranking.vars, &p).unwrap();
916        assert!(gb.contains(&truth_poly), "I'' - I is a consequence");
917
918        // Nothing forces the state to vanish identically.
919        for false_claim in [i, di, d2i] {
920            let q = expr_to_gbpoly(false_claim, &ranking.vars, &p).unwrap();
921            assert!(
922                !gb.contains(&q),
923                "prolongation asserted a jet of the state vanishes identically"
924            );
925        }
926    }
927
928    /// The jet ranking has to be append-only across prolongation rounds.
929    ///
930    /// It used to be recomputed from scratch each round, and
931    /// [`vars_for_dae`] appends the symbols it scrapes out of the equations
932    /// *after* the declared jets — so introducing `d²x/dt²` pushed the trailing
933    /// parameter `a` one slot to the right, under polynomials that had already
934    /// been padded on the assumption that slot `i` still meant what it meant
935    /// last round.  On `x' = a·x`, one prolongation, that silently turned the
936    /// input equation into a relation about `d²x/dt²` and lost it from the
937    /// basis entirely.
938    #[test]
939    fn the_jet_ranking_is_append_only() {
940        let p = pool();
941        let t = p.symbol("t", Domain::Real);
942        let x = p.symbol("x", Domain::Real);
943        let dx = p.symbol("dx/dt", Domain::Real);
944        let a = p.symbol("a", Domain::Real);
945        // x' = a·x, with `a` an ordinary ring variable (no params here).
946        let eq = p.add(vec![dx, p.mul(vec![p.integer(-1), a, x])]);
947        let dae = DAE::new(vec![eq], vec![x], vec![dx], t);
948
949        let (res, ranking) =
950            rosenfeld_groebner_ranked(&dae, &p, MonomialOrder::GRevLex, 1).unwrap();
951        let gb = res.final_basis.expect("consistent");
952
953        // The system's own equation is a consequence of itself.
954        let src = expr_to_gbpoly(eq, &ranking.vars, &p).unwrap();
955        assert!(gb.contains(&src), "the input equation left the ideal");
956
957        // ...and x·x'' - x' is not: it would need a·x = 1.
958        let d2x = p.symbol("ddx/dt/dt", Domain::Real);
959        let bogus = p.add(vec![p.mul(vec![x, d2x]), p.mul(vec![p.integer(-1), dx])]);
960        let q = expr_to_gbpoly(bogus, &ranking.vars, &p).unwrap();
961        assert!(
962            !gb.contains(&q),
963            "a shifted exponent slot invented a relation"
964        );
965    }
966
967    /// The parametric route reads the input–output relation of an ODE model
968    /// straight out of the DAE, with the rate constant in `Q(a)` rather than as
969    /// a fourth ring variable (2026-08-19 issue #16).
970    #[test]
971    fn parametric_prolongation_yields_the_io_relation() {
972        let p = pool();
973        let t = p.symbol("t", Domain::Real);
974        let x = p.symbol("x", Domain::Real);
975        let y = p.symbol("y", Domain::Real);
976        let dx = p.symbol("dx/dt", Domain::Real);
977        let dy = p.symbol("dy/dt", Domain::Real);
978        let a = p.symbol("a", Domain::Real);
979        // x' = -a·x, y = x  =>  y' + a·y = 0.
980        let eq1 = p.add(vec![dx, p.mul(vec![a, x])]);
981        let eq2 = p.add(vec![y, p.mul(vec![p.integer(-1), x])]);
982        let dae = DAE::new(vec![eq1, eq2], vec![x, y], vec![dx, dy], t);
983
984        let (r, ranking) = rosenfeld_groebner_parametric(
985            &dae,
986            &p,
987            &[a],
988            ParametricProlongOpts {
989                order: MonomialOrder::Lex,
990                max_prolong_rounds: 3,
991                eliminate: &[x],
992                minimal: true,
993            },
994        )
995        .unwrap();
996
997        // One derivative of the output is enough, not three.
998        assert_eq!(r.minimal_prolongation_rounds, Some(1));
999        assert_eq!(r.prolongation_rounds, 1);
1000        assert!(
1001            !ranking.vars.contains(&a),
1002            "`a` must not be a ring variable"
1003        );
1004
1005        let gb = r.final_basis.expect("consistent");
1006        let state_slots: Vec<usize> = ranking
1007            .vars
1008            .iter()
1009            .enumerate()
1010            .filter(|(_, &v)| {
1011                let name = p.with(v, |d| match d {
1012                    ExprData::Symbol { name, .. } => name.clone(),
1013                    _ => String::new(),
1014                });
1015                name.trim_start_matches('d').split('/').next() == Some("x")
1016            })
1017            .map(|(i, _)| i)
1018            .collect();
1019        let io = gb.eliminate(&state_slots);
1020        assert_eq!(io.len(), 1);
1021
1022        let relation = p.add(vec![dy, p.mul(vec![a, y])]);
1023        let q = expr_to_param_gbpoly(relation, &ranking.vars, &[a], &p).unwrap();
1024        assert!(io.contains(&q), "y' + a·y = 0 is the input–output relation");
1025    }
1026
1027    #[test]
1028    fn textbook_library_runs() {
1029        // Ten tiny polynomial DAE snapshots (autonomous, explicit first derivatives).
1030        let mut n_ok = 0;
1031        for _ in 0..10 {
1032            let p = pool();
1033            let t = p.symbol("t", Domain::Real);
1034            let x = p.symbol("x", Domain::Real);
1035            let y = p.symbol("y", Domain::Real);
1036            let dx = p.symbol("dx/dt", Domain::Real);
1037            let dy = p.symbol("dy/dt", Domain::Real);
1038            // Mixed batch: linear dynamics, coupling, and one inconsistent pair.
1039            let (eqs, v, d, consistent) = match n_ok % 3 {
1040                0 => {
1041                    // harmonic x' = y, y' = -x
1042                    let e1 = p.add(vec![dx, p.mul(vec![p.integer(-1), y])]);
1043                    let e2 = p.add(vec![dy, x]);
1044                    (vec![e1, e2], vec![x, y], vec![dx, dy], true)
1045                }
1046                1 => {
1047                    // decoupled exponentials as linear place-holders: x'=x, y'=-y
1048                    let e1 = p.add(vec![dx, p.mul(vec![p.integer(-1), x])]);
1049                    let e2 = p.add(vec![dy, y]);
1050                    (vec![e1, e2], vec![x, y], vec![dx, dy], true)
1051                }
1052                _ => {
1053                    let e1 = p.add(vec![dx, p.mul(vec![p.integer(-1), x])]);
1054                    let e2 = p.add(vec![dx, p.mul(vec![p.integer(-1), x]), p.integer(-1)]);
1055                    (vec![e1, e2], vec![x], vec![dx], false)
1056                }
1057            };
1058            let dae = DAE::new(eqs, v, d, t);
1059            let r = rosenfeld_groebner(&dae, &p, MonomialOrder::GRevLex).unwrap();
1060            assert_eq!(r.consistent, consistent);
1061            n_ok += 1;
1062        }
1063        assert_eq!(n_ok, 10);
1064    }
1065}