Skip to main content

alkahest_cas/calculus/
asymptotic.rs

1//! Asymptotic expansions at infinity in the Poincaré sense (§2.4).
2//!
3//! Given `f(x)` and a variable `x`, [`asymptotic_expand`] returns an ordered
4//! list of asymptotic terms `g₁, g₂, …` such that
5//!
6//! ```text
7//! f(x) ~ g₁(x) + g₂(x) + …   as x → +∞,
8//! ```
9//!
10//! with each `gₖ₊₁ = o(gₖ)` as `x → +∞` (the defining property of a Poincaré
11//! asymptotic sequence). The terms are returned most-significant first.
12//!
13//! # Strategy
14//!
15//! The core engine is the substitution `x = 1/t` followed by a (Laurent /
16//! Puiseux-lite) series of `f(1/t)` at `t → 0⁺`, reusing
17//! [`mod@crate::calculus::series`]. A term `c · t^e` of that series maps back to
18//! `c · x^{−e}`; the polynomial-growth part (negative `t`-powers, i.e. positive
19//! `x`-powers) is carried along automatically. This covers:
20//!
21//! * rational functions — e.g. `(x+1)/(x−1) ~ 1 + 2/x + 2/x² + …`;
22//! * algebraic functions analytic at ∞ — e.g.
23//!   `√(x²+1) ~ x + 1/(2x) − 1/(8x³) + …`;
24//! * compositions analytic at ∞ — e.g. `x·sin(1/x) ~ 1 − 1/(6x²) + …`,
25//!   `e^{1/x}·x ~ x + 1 + 1/(2x) + …`.
26//!
27//! Beyond pure power scales, a **leading log/exp scale is peeled
28//! multiplicatively** for a restricted but common shape: `log(P(x))` arguments
29//! (e.g. `log(x+1) ~ log x + 1/x − 1/(2x²) + …`) and a single dominant
30//! `log(x)` / `exp(g(x))` factor whose power-scale cofactor is then expanded.
31//! Genuinely scale-iterated expansions (nested exp-log hierarchies,
32//! Γ / Stirling asymptotics) are **out of scope** and decline honestly.
33//!
34//! # Correctness gate
35//!
36//! Every returned expansion is gated by a numeric `o()`-check: at
37//! `x = 10², 10⁴, 10⁶` the residual `|f − Σ₁..ₖ gᵢ|` must be bounded by (and
38//! shrink consistently with) the next term `gₖ₊₁`. Terms that fail the gate are
39//! dropped from the tail; if no term survives, the call declines rather than
40//! emit an unverified expansion.
41
42use crate::calculus::series::{local_expansion, LocalExpansion};
43use crate::diff::DiffError;
44use crate::jit::eval_interp;
45use crate::kernel::{subs, Domain, ExprData, ExprId, ExprPool};
46use crate::simplify::simplify;
47use std::collections::HashMap;
48use std::fmt;
49
50// ---------------------------------------------------------------------------
51// Public types
52// ---------------------------------------------------------------------------
53
54/// One term `gₖ(x)` of an asymptotic expansion at `+∞`.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56pub struct AsymptoticTerm {
57    /// The symbolic term, expressed in the original variable `x`.
58    pub expr: ExprId,
59}
60
61impl AsymptoticTerm {
62    /// The symbolic term `gₖ(x)`.
63    pub fn expr(self) -> ExprId {
64        self.expr
65    }
66}
67
68/// Result of [`asymptotic_expand`]: the ordered asymptotic terms (most
69/// significant first).
70#[derive(Clone, Debug)]
71pub struct AsymptoticExpansion {
72    /// Asymptotic terms `g₁, g₂, …`, ordered most-significant first.
73    pub terms: Vec<AsymptoticTerm>,
74}
75
76impl AsymptoticExpansion {
77    /// The bare term expressions, ordered most-significant first.
78    pub fn term_exprs(&self) -> Vec<ExprId> {
79        self.terms.iter().map(|t| t.expr).collect()
80    }
81
82    /// The sum `g₁ + g₂ + … + g_k` of all surviving terms (the truncated
83    /// asymptotic approximation).
84    pub fn partial_sum(&self, pool: &ExprPool) -> ExprId {
85        if self.terms.is_empty() {
86            return pool.integer(0_i32);
87        }
88        let xs: Vec<ExprId> = self.terms.iter().map(|t| t.expr).collect();
89        simplify(pool.add(xs), pool).value
90    }
91}
92
93/// Failure modes for [`asymptotic_expand`].
94#[derive(Debug)]
95pub enum AsymptoticError {
96    /// `n_terms` must be ≥ 1.
97    InvalidTermCount,
98    /// The substitution `x = 1/t` series failed (function not analytic /
99    /// Laurent-expandable at ∞ with the available machinery).
100    SeriesFailed,
101    /// A derivative needed for an internal expansion failed.
102    Diff(DiffError),
103    /// The numeric `o()`-gate rejected every candidate term — the expansion
104    /// could not be verified, so nothing is emitted.
105    GateFailed,
106    /// No implemented scale (power / single log-exp peel) matched the input.
107    UnsupportedScale,
108}
109
110impl fmt::Display for AsymptoticError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            AsymptoticError::InvalidTermCount => write!(f, "n_terms must be >= 1"),
114            AsymptoticError::SeriesFailed => {
115                write!(f, "could not form a series of f(1/t) at t -> 0")
116            }
117            AsymptoticError::Diff(e) => write!(f, "{e}"),
118            AsymptoticError::GateFailed => {
119                write!(f, "numeric o()-gate rejected the asymptotic expansion")
120            }
121            AsymptoticError::UnsupportedScale => {
122                write!(f, "asymptotic scale not supported by current rules")
123            }
124        }
125    }
126}
127
128impl std::error::Error for AsymptoticError {
129    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
130        match self {
131            AsymptoticError::Diff(e) => Some(e),
132            _ => None,
133        }
134    }
135}
136
137impl crate::errors::AlkahestError for AsymptoticError {
138    fn code(&self) -> &'static str {
139        match self {
140            AsymptoticError::InvalidTermCount => "E-ASYMPT-001",
141            AsymptoticError::SeriesFailed => "E-ASYMPT-002",
142            AsymptoticError::Diff(_) => "E-ASYMPT-003",
143            AsymptoticError::GateFailed => "E-ASYMPT-004",
144            AsymptoticError::UnsupportedScale => "E-ASYMPT-005",
145        }
146    }
147
148    fn remediation(&self) -> Option<&'static str> {
149        Some(match self {
150            AsymptoticError::InvalidTermCount => "pass n_terms >= 1",
151            AsymptoticError::SeriesFailed => {
152                "the function may not be analytic/Laurent-expandable at infinity; \
153                 try a simpler form or fewer terms"
154            }
155            AsymptoticError::Diff(_) => {
156                "ensure all functions are registered primitives with differentiation rules"
157            }
158            AsymptoticError::GateFailed => {
159                "the expansion could not be numerically verified at large x; \
160                 the function may have an oscillatory or non-power-scale tail"
161            }
162            AsymptoticError::UnsupportedScale => {
163                "exp/log scale hierarchies and Gamma/Stirling asymptotics are out of scope; \
164                 power-scale (rational/algebraic) and single log/exp peels are supported"
165            }
166        })
167    }
168}
169
170impl From<DiffError> for AsymptoticError {
171    fn from(e: DiffError) -> Self {
172        AsymptoticError::Diff(e)
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Entry point
178// ---------------------------------------------------------------------------
179
180/// Compute the asymptotic expansion of `f` in `var` as `var → +∞`, returning at
181/// most `n_terms` ordered terms (most significant first).
182///
183/// See the [module documentation](self) for the strategy, supported scales, and
184/// the numeric `o()`-gate that every emitted expansion must pass.
185pub fn asymptotic_expand(
186    f: ExprId,
187    var: ExprId,
188    n_terms: usize,
189    pool: &ExprPool,
190) -> Result<AsymptoticExpansion, AsymptoticError> {
191    if n_terms == 0 {
192        return Err(AsymptoticError::InvalidTermCount);
193    }
194
195    let f = simplify(f, pool).value;
196
197    // 1. Pure power-scale core (substitution x = 1/t).
198    if let Ok(exp) = power_scale_expand(f, var, n_terms, pool) {
199        if !exp.terms.is_empty() {
200            return Ok(exp);
201        }
202    }
203
204    // 2. Log-scale peel: f = log(P(x)) + rest, or a single log(x)/exp(g) factor.
205    if let Some(exp) = try_log_peel(f, var, n_terms, pool)? {
206        if !exp.terms.is_empty() {
207            return Ok(exp);
208        }
209    }
210
211    Err(AsymptoticError::UnsupportedScale)
212}
213
214// ---------------------------------------------------------------------------
215// Power-scale core: x = 1/t, series at t -> 0, map back.
216// ---------------------------------------------------------------------------
217
218/// Build a raw (ungated) list of power-scale terms `cᵢ · x^{−eᵢ}` from the
219/// Laurent expansion of `f(1/t)` at `t → 0`, ordered by decreasing `x`-power.
220///
221/// `order` is the series truncation in `t`; it is chosen larger than `n_terms`
222/// so that low-order zero coefficients do not starve the result.
223fn power_scale_terms_raw(
224    f: ExprId,
225    var: ExprId,
226    order: u32,
227    pool: &ExprPool,
228) -> Result<Vec<(i64, ExprId)>, AsymptoticError> {
229    let t = pool.symbol("__asy_t", Domain::Positive);
230    let inv_t = pool.pow(t, pool.integer(-1_i32));
231    let mut m = HashMap::new();
232    m.insert(var, inv_t);
233    let f_of_t = simplify(subs(f, &m, pool), pool).value;
234
235    // Regularize symbolically: f(1/t) = t^val · u(t) with u analytic & nonzero
236    // at t = 0. `regularize_at_zero` extracts the integer t-valuation `val`
237    // (possibly negative — a pole / polynomial-growth factor) and the analytic
238    // part `u`, pushing t-powers inside radicals/powers so that `local_expansion`
239    // (Taylor) sees an honest analytic function.
240    let (val, analytic) =
241        regularize_at_zero(f_of_t, t, pool).ok_or(AsymptoticError::SeriesFailed)?;
242
243    let LocalExpansion {
244        valuation: tay_val,
245        coeffs,
246        ..
247    } = local_expansion(analytic, t, pool.integer(0_i32), order, pool)
248        .map_err(|_| AsymptoticError::SeriesFailed)?;
249
250    // u's i-th coefficient sits at t^{tay_val+i}; with the extracted factor the
251    // original f(1/t) term is at t-power (val + tay_val + i), i.e. x-power
252    // −(val + tay_val + i).
253    let mut out: Vec<(i64, ExprId)> = Vec::new();
254    for (i, &c) in coeffs.iter().enumerate() {
255        // Coefficients are var-free constants but may carry unfolded
256        // `sin(0)`/`cos(0)`/`exp(0)` heads; fold them and drop numeric zeros.
257        let c = fold_constant(c, pool);
258        if is_numeric_zero(c, pool) {
259            continue;
260        }
261        let t_pow = val + tay_val as i64 + i as i64;
262        let x_pow = -t_pow;
263        let term = make_power_term(c, var, x_pow, pool);
264        out.push((x_pow, term));
265    }
266    // Most significant (largest x-power) first.
267    out.sort_by_key(|p| std::cmp::Reverse(p.0));
268    Ok(out)
269}
270
271/// Decompose `g(t) = t^val · u(t)` with `u` analytic at `t = 0`, returning
272/// `(val, u)`. The integer `val` may be negative (pole). Returns `None` when the
273/// structure is outside the integer-power scale (e.g. a genuine fractional
274/// `t`-valuation / Puiseux branch, or an unsupported head).
275///
276/// This is a structural valuation calculus that pushes `t`-powers through
277/// `Add`/`Mul`/`Pow`/elementary `Func`s so the analytic remainder is honestly
278/// Taylor-expandable. `val` is recovered exactly; the analytic part is left
279/// symbolic for [`local_expansion`].
280fn regularize_at_zero(g: ExprId, t: ExprId, pool: &ExprPool) -> Option<(i64, ExprId)> {
281    let g = simplify(g, pool).value;
282    match pool.get(g) {
283        // Constants and other-variable atoms: valuation 0.
284        ExprData::Integer(_) | ExprData::Rational(_) | ExprData::Float(_) => Some((0, g)),
285        ExprData::Symbol { .. } => {
286            if g == t {
287                Some((1, pool.integer(1_i32)))
288            } else {
289                Some((0, g))
290            }
291        }
292        ExprData::Mul(xs) => {
293            let mut val = 0i64;
294            let mut parts: Vec<ExprId> = Vec::new();
295            for x in xs {
296                let (v, u) = regularize_at_zero(x, t, pool)?;
297                val += v;
298                parts.push(u);
299            }
300            Some((val, simplify(pool.mul(parts), pool).value))
301        }
302        ExprData::Pow { base, exp } => {
303            // Only integer or rational exponents with integer resulting valuation.
304            let (vb, ub) = regularize_at_zero(base, t, pool)?;
305            let exp_q = rational_value(exp, pool)?;
306            // valuation = vb * exp; must be an integer for the integer scale.
307            let num = exp_q.numer().clone();
308            let den = exp_q.denom().clone();
309            let prod = rug::Integer::from(vb) * &num;
310            // prod / den must be an integer.
311            if den == 0 {
312                return None;
313            }
314            let (q, r) = prod.div_rem(den.clone());
315            if r != 0 {
316                return None; // fractional valuation: Puiseux, out of integer scale
317            }
318            let val = q.to_i64()?;
319            // analytic part = ub^exp (ub analytic & nonzero ⇒ ub^exp analytic).
320            let analytic = simplify(pool.pow(ub, exp), pool).value;
321            Some((val, analytic))
322        }
323        ExprData::Add(xs) => {
324            // Regularize each summand, factor out the minimal valuation.
325            let mut pieces: Vec<(i64, ExprId)> = Vec::with_capacity(xs.len());
326            let mut vmin = i64::MAX;
327            for x in &xs {
328                let (v, u) = regularize_at_zero(*x, t, pool)?;
329                vmin = vmin.min(v);
330                pieces.push((v, u));
331            }
332            if vmin == i64::MAX {
333                return None;
334            }
335            let mut summands: Vec<ExprId> = Vec::with_capacity(pieces.len());
336            for (v, u) in pieces {
337                let shift = v - vmin; // ≥ 0
338                let term = if shift == 0 {
339                    u
340                } else {
341                    simplify(pool.mul(vec![pool.pow(t, pool.integer(shift)), u]), pool).value
342                };
343                summands.push(term);
344            }
345            let analytic = simplify(pool.add(summands), pool).value;
346            // The factored sum is analytic; its own valuation may be > 0 if the
347            // leading terms cancel — local_expansion will pick that up via tay_val.
348            Some((vmin, analytic))
349        }
350        ExprData::Func { name, args } if args.len() == 1 => {
351            let (va, ua) = regularize_at_zero(args[0], t, pool)?;
352            match name.as_str() {
353                // sqrt(t^va · ua) = t^{va/2} · sqrt(ua) when va is even (else the
354                // result is a genuine Puiseux branch, outside the integer scale).
355                "sqrt" => {
356                    if va % 2 != 0 {
357                        return None;
358                    }
359                    // Reconstruct the analytic argument t^{va}·ua only when va==0;
360                    // when va<0 the sqrt would diverge — but va even means
361                    // t^{va/2}·sqrt(ua) with ua analytic & nonzero is the answer.
362                    let analytic = simplify(pool.func("sqrt", vec![ua]), pool).value;
363                    Some((va / 2, analytic))
364                }
365                // Elementary transcendental heads that are analytic wherever their
366                // argument is analytic (finite at t=0, i.e. valuation ≥ 0). The
367                // composition is then analytic at t=0; `local_expansion` (Taylor)
368                // recovers any internal zero (e.g. sin(t) ~ t). A divergent
369                // argument (valuation < 0) is outside this scale.
370                "sin" | "cos" | "tan" | "exp" | "cosh" | "sinh" | "tanh" | "gamma" => {
371                    if va < 0 {
372                        return None;
373                    }
374                    // Rebuild the full (analytic) argument t^{va}·ua.
375                    let full_arg = if va == 0 {
376                        ua
377                    } else {
378                        simplify(pool.mul(vec![pool.pow(t, pool.integer(va)), ua]), pool).value
379                    };
380                    let analytic = simplify(pool.func(name, vec![full_arg]), pool).value;
381                    Some((0, analytic))
382                }
383                // log is analytic only at a finite *nonzero* argument: require
384                // valuation 0 and a nonzero constant term, so log(arg) is itself
385                // analytic (valuation 0) at t = 0 (e.g. log(1+t)).
386                "log" => {
387                    if va != 0 {
388                        return None;
389                    }
390                    let at0 = eval_at_zero(ua, t, pool)?;
391                    if at0 == 0.0 {
392                        return None;
393                    }
394                    let analytic = simplify(pool.func("log", vec![ua]), pool).value;
395                    Some((0, analytic))
396                }
397                _ => None,
398            }
399        }
400        _ => None,
401    }
402}
403
404/// Fold constant `sin(0)`/`cos(0)`/`exp(0)`/… heads that the generic simplifier
405/// leaves intact inside Taylor coefficients, recursing through `Add`/`Mul`/`Pow`.
406fn fold_constant(e: ExprId, pool: &ExprPool) -> ExprId {
407    let e = simplify(e, pool).value;
408    match pool.get(e) {
409        ExprData::Add(xs) => {
410            let ys: Vec<ExprId> = xs.iter().map(|x| fold_constant(*x, pool)).collect();
411            simplify(pool.add(ys), pool).value
412        }
413        ExprData::Mul(xs) => {
414            let ys: Vec<ExprId> = xs.iter().map(|x| fold_constant(*x, pool)).collect();
415            simplify(pool.mul(ys), pool).value
416        }
417        ExprData::Pow { base, exp } => {
418            let b = fold_constant(base, pool);
419            let x = fold_constant(exp, pool);
420            simplify(pool.pow(b, x), pool).value
421        }
422        ExprData::Func { name, args } if args.len() == 1 => {
423            let inner = fold_constant(args[0], pool);
424            if matches!(pool.get(inner), ExprData::Integer(n) if n.0 == 0) {
425                match name.as_str() {
426                    "sin" | "tan" | "sinh" | "tanh" => return pool.integer(0_i32),
427                    "cos" | "cosh" | "exp" => return pool.integer(1_i32),
428                    _ => {}
429                }
430            }
431            simplify(pool.func(name, vec![inner]), pool).value
432        }
433        _ => e,
434    }
435}
436
437/// True if `e` evaluates numerically to (approximately) zero, or is the
438/// structural integer/rational zero.
439fn is_numeric_zero(e: ExprId, pool: &ExprPool) -> bool {
440    if matches!(pool.get(e), ExprData::Integer(n) if n.0 == 0) {
441        return true;
442    }
443    if let ExprData::Rational(r) = pool.get(e) {
444        return r.0 == 0;
445    }
446    match eval_interp(e, &HashMap::new(), pool) {
447        Some(v) => v == 0.0,
448        None => false,
449    }
450}
451
452/// Numeric value of `e` at `t = 0` (a small positive sample), used to check that
453/// an analytic factor is nonzero there.
454fn eval_at_zero(e: ExprId, t: ExprId, pool: &ExprPool) -> Option<f64> {
455    let mut env = HashMap::new();
456    env.insert(t, 1.0e-6f64);
457    eval_interp(e, &env, pool).filter(|v| v.is_finite())
458}
459
460/// Extract a rational/integer constant exponent as a `rug::Rational`.
461fn rational_value(e: ExprId, pool: &ExprPool) -> Option<rug::Rational> {
462    match pool.get(e) {
463        ExprData::Integer(n) => Some(rug::Rational::from((n.0.clone(), rug::Integer::from(1)))),
464        ExprData::Rational(r) => Some(r.0.clone()),
465        _ => None,
466    }
467}
468
469fn make_power_term(coeff: ExprId, var: ExprId, x_pow: i64, pool: &ExprPool) -> ExprId {
470    let pow = if x_pow == 0 {
471        pool.integer(1_i32)
472    } else if x_pow == 1 {
473        var
474    } else {
475        pool.pow(var, pool.integer(x_pow))
476    };
477    simplify(pool.mul(vec![coeff, pow]), pool).value
478}
479
480fn power_scale_expand(
481    f: ExprId,
482    var: ExprId,
483    n_terms: usize,
484    pool: &ExprPool,
485) -> Result<AsymptoticExpansion, AsymptoticError> {
486    // Request extra order so we still find n_terms nonzero terms past gaps.
487    let order = (n_terms as u32).saturating_mul(2).saturating_add(8).min(40);
488    let raw = power_scale_terms_raw(f, var, order, pool)?;
489    if raw.is_empty() {
490        return Err(AsymptoticError::SeriesFailed);
491    }
492    let candidate: Vec<ExprId> = raw.iter().map(|(_, e)| *e).take(n_terms).collect();
493    let gated = gate_terms(f, var, &candidate, pool);
494    if gated.is_empty() {
495        return Err(AsymptoticError::GateFailed);
496    }
497    Ok(AsymptoticExpansion {
498        terms: gated
499            .into_iter()
500            .map(|expr| AsymptoticTerm { expr })
501            .collect(),
502    })
503}
504
505// ---------------------------------------------------------------------------
506// Log/exp scale peeling (restricted).
507// ---------------------------------------------------------------------------
508
509/// Handle a single dominant `log`/`exp` scale.
510///
511/// Two shapes are covered:
512///
513/// * `f = log(P(x))`, expanded as `log(x^d) + log(P/x^d)` where `d = deg P`;
514///   the second factor is analytic at ∞ and power-expanded. Gives e.g.
515///   `log(x+1) ~ log x + 1/x − 1/(2x²) + …`.
516/// * `f = log(x) · h(x)` or `exp(g(x)) · h(x)` with a single such leading
517///   factor and a power-expandable cofactor `h`.
518fn try_log_peel(
519    f: ExprId,
520    var: ExprId,
521    n_terms: usize,
522    pool: &ExprPool,
523) -> Result<Option<AsymptoticExpansion>, AsymptoticError> {
524    // Shape: f is exactly log(arg).
525    if let ExprData::Func { name, args } = pool.get(f) {
526        if name == "log" && args.len() == 1 {
527            return log_of_arg_peel(f, args[0], var, n_terms, pool);
528        }
529    }
530
531    // Shape: f is a product with exactly one log(x)/exp(g) factor and a
532    // power-expandable cofactor.
533    if let ExprData::Mul(factors) = pool.get(f) {
534        if let Some(exp) = mul_with_scale_factor(&factors, var, n_terms, pool)? {
535            return Ok(Some(exp));
536        }
537    }
538
539    Ok(None)
540}
541
542/// Expand `log(arg)` where `arg → +∞` polynomially: peel `log(x^d)` and
543/// power-expand the analytic remainder `log(arg / x^d)`.
544fn log_of_arg_peel(
545    f: ExprId,
546    arg: ExprId,
547    var: ExprId,
548    n_terms: usize,
549    pool: &ExprPool,
550) -> Result<Option<AsymptoticExpansion>, AsymptoticError> {
551    // Determine the dominant integer power d of x in arg via the t-substitution
552    // valuation of arg(1/t): arg ~ c · x^d means arg(1/t) ~ c · t^{-d}.
553    let t = pool.symbol("__asy_t", Domain::Positive);
554    let inv_t = pool.pow(t, pool.integer(-1_i32));
555    let mut m = HashMap::new();
556    m.insert(var, inv_t);
557    let arg_t = simplify(subs(arg, &m, pool), pool).value;
558    let order = (n_terms as u32).saturating_add(6).min(40);
559    let (val, _analytic) = match regularize_at_zero(arg_t, t, pool) {
560        Some(p) => p,
561        None => return Ok(None),
562    };
563    let d = -val; // x-power of arg's leading behaviour
564    if d <= 0 {
565        // arg does not grow polynomially; not a log-at-infinity peel.
566        return Ok(None);
567    }
568
569    // log(arg) = d·log(x) + log(arg / x^d). The cofactor arg / x^d → leading
570    // constant and is power-expandable.
571    let x_d = pool.pow(var, pool.integer(d));
572    let cofactor = simplify(
573        pool.mul(vec![arg, pool.pow(x_d, pool.integer(-1_i32))]),
574        pool,
575    )
576    .value;
577    let log_cofactor = pool.func("log", vec![cofactor]);
578
579    // Leading log term.
580    let log_x = pool.func("log", vec![var]);
581    let lead = simplify(pool.mul(vec![pool.integer(d), log_x]), pool).value;
582
583    // Power-expand the analytic remainder log(cofactor); it has a finite limit,
584    // so its expansion is a genuine power series in 1/x with no log.
585    let remainder = power_scale_terms_raw(log_cofactor, var, order, pool)?;
586    let mut candidate: Vec<ExprId> = vec![lead];
587    for (_, e) in remainder.into_iter().take(n_terms.saturating_sub(1)) {
588        // Drop a structurally-zero constant term (limit of log cofactor is 0).
589        if matches!(pool.get(e), ExprData::Integer(n) if n.0 == 0) {
590            continue;
591        }
592        candidate.push(e);
593    }
594
595    let gated = gate_terms(f, var, &candidate, pool);
596    if gated.is_empty() {
597        return Ok(None);
598    }
599    Ok(Some(AsymptoticExpansion {
600        terms: gated
601            .into_iter()
602            .map(|expr| AsymptoticTerm { expr })
603            .collect(),
604    }))
605}
606
607/// `f = scale · h` with one leading `log(x)`/`exp(g)` factor and a
608/// power-expandable cofactor `h`. Expand `h ~ Σ hᵢ` and distribute the scale.
609fn mul_with_scale_factor(
610    factors: &[ExprId],
611    var: ExprId,
612    n_terms: usize,
613    pool: &ExprPool,
614) -> Result<Option<AsymptoticExpansion>, AsymptoticError> {
615    // Identify a single scale factor: log(var) or exp(g) with g depending on var.
616    let mut scale: Option<ExprId> = None;
617    let mut rest: Vec<ExprId> = Vec::new();
618    for &fac in factors {
619        let is_scale = match pool.get(fac) {
620            ExprData::Func { name, args } if name == "log" && args.len() == 1 => args[0] == var,
621            ExprData::Func { name, args } if name == "exp" && args.len() == 1 => {
622                depends_on(args[0], var, pool)
623            }
624            _ => false,
625        };
626        if is_scale && scale.is_none() {
627            scale = Some(fac);
628        } else {
629            rest.push(fac);
630        }
631    }
632    let Some(scale) = scale else {
633        return Ok(None);
634    };
635    let cofactor = if rest.is_empty() {
636        pool.integer(1_i32)
637    } else {
638        simplify(pool.mul(rest), pool).value
639    };
640    // The cofactor must be power-expandable on its own.
641    let order = (n_terms as u32).saturating_mul(2).saturating_add(8).min(40);
642    let co_terms = power_scale_terms_raw(cofactor, var, order, pool)?;
643    if co_terms.is_empty() {
644        return Ok(None);
645    }
646    let candidate: Vec<ExprId> = co_terms
647        .into_iter()
648        .take(n_terms)
649        .map(|(_, e)| simplify(pool.mul(vec![scale, e]), pool).value)
650        .collect();
651
652    let f = simplify(pool.mul(factors.to_vec()), pool).value;
653    let gated = gate_terms(f, var, &candidate, pool);
654    if gated.is_empty() {
655        return Ok(None);
656    }
657    Ok(Some(AsymptoticExpansion {
658        terms: gated
659            .into_iter()
660            .map(|expr| AsymptoticTerm { expr })
661            .collect(),
662    }))
663}
664
665fn depends_on(expr: ExprId, var: ExprId, pool: &ExprPool) -> bool {
666    if expr == var {
667        return true;
668    }
669    match pool.get(expr) {
670        ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().any(|a| depends_on(*a, var, pool)),
671        ExprData::Pow { base, exp } => depends_on(base, var, pool) || depends_on(exp, var, pool),
672        ExprData::Func { args, .. } => args.iter().any(|a| depends_on(*a, var, pool)),
673        _ => false,
674    }
675}
676
677// ---------------------------------------------------------------------------
678// Numeric o()-gate.
679// ---------------------------------------------------------------------------
680
681/// Sample points at which the asymptotic gate is checked.
682const GATE_POINTS: [f64; 3] = [1.0e2, 1.0e4, 1.0e6];
683
684/// Relative slack allowed in the residual ≤ |next term| comparison (accounts
685/// for floating-point error and the fact that the *next* asymptotic term only
686/// bounds the residual up to a constant for finitely many terms).
687const GATE_SLACK: f64 = 8.0;
688
689/// Filter `candidate` (ordered, most-significant first) down to the longest
690/// verified prefix: term `k+1` must be `o(term k)` and the residual after `k`
691/// terms must be controlled by term `k+1` at every gate point.
692///
693/// Returns the surviving prefix (possibly empty).
694fn gate_terms(f: ExprId, var: ExprId, candidate: &[ExprId], pool: &ExprPool) -> Vec<ExprId> {
695    if candidate.is_empty() {
696        return Vec::new();
697    }
698
699    // Numerically evaluate f at each gate point.
700    let mut f_vals = [0.0f64; GATE_POINTS.len()];
701    for (j, &xv) in GATE_POINTS.iter().enumerate() {
702        let mut env = HashMap::new();
703        env.insert(var, xv);
704        match eval_interp(f, &env, pool) {
705            Some(v) if v.is_finite() => f_vals[j] = v,
706            _ => return Vec::new(), // cannot evaluate f → cannot gate → decline
707        }
708    }
709
710    // term_vals[k][j]
711    let mut term_vals: Vec<[f64; GATE_POINTS.len()]> = Vec::with_capacity(candidate.len());
712    for &term in candidate {
713        let mut row = [0.0f64; GATE_POINTS.len()];
714        for (j, &xv) in GATE_POINTS.iter().enumerate() {
715            let mut env = HashMap::new();
716            env.insert(var, xv);
717            match eval_interp(term, &env, pool) {
718                Some(v) if v.is_finite() => row[j] = v,
719                _ => {
720                    // Term not numerically evaluable — stop accepting here.
721                    row = [f64::NAN; GATE_POINTS.len()];
722                    break;
723                }
724            }
725        }
726        term_vals.push(row);
727    }
728
729    let mut accepted = 0usize;
730    let mut partial = [0.0f64; GATE_POINTS.len()];
731
732    for k in 0..candidate.len() {
733        let row = term_vals[k];
734        if row.iter().any(|v| !v.is_finite()) {
735            break;
736        }
737
738        // o()-check vs previous term: |term_k| < |term_{k-1}| at large x, and
739        // the ratio should be shrinking across the gate points.
740        if k > 0 {
741            let prev = term_vals[k - 1];
742            let mut ok = true;
743            let mut last_ratio = f64::INFINITY;
744            for j in 0..GATE_POINTS.len() {
745                let denom = prev[j].abs();
746                if denom == 0.0 {
747                    ok = false;
748                    break;
749                }
750                let ratio = row[j].abs() / denom;
751                if ratio > 1.0 {
752                    ok = false;
753                    break;
754                }
755                if j > 0 && ratio > last_ratio * (1.0 + 1e-9) {
756                    // ratio not decreasing → not a genuine asymptotic refinement.
757                    ok = false;
758                    break;
759                }
760                last_ratio = ratio;
761            }
762            if !ok {
763                break;
764            }
765        }
766
767        // Tentatively add this term and check the residual is controlled by it.
768        let mut next_partial = partial;
769        for j in 0..GATE_POINTS.len() {
770            next_partial[j] += row[j];
771        }
772
773        let mut residual_ok = true;
774        let mut last_rel = f64::INFINITY;
775        for j in 0..GATE_POINTS.len() {
776            let residual = (f_vals[j] - next_partial[j]).abs();
777            let scale = row[j].abs();
778            // After adding term k, the residual must be no bigger than this
779            // term (up to slack); this is the "term k+1 = o(term k)" guarantee
780            // expressed against the realized residual.
781            if residual > scale * GATE_SLACK + 1e-12 {
782                residual_ok = false;
783                break;
784            }
785            // Residual (relative to current term magnitude) should not grow as
786            // x increases.
787            let rel = if scale > 0.0 {
788                residual / scale
789            } else {
790                residual
791            };
792            if j > 0 && rel > last_rel * GATE_SLACK + 1e-12 {
793                residual_ok = false;
794                break;
795            }
796            last_rel = rel;
797        }
798        if !residual_ok {
799            break;
800        }
801
802        partial = next_partial;
803        accepted = k + 1;
804    }
805
806    candidate[..accepted].to_vec()
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::kernel::Domain;
813
814    fn approx_eq_expr(a: ExprId, b: ExprId, var: ExprId, pool: &ExprPool) -> bool {
815        // Structural-or-numeric equality across a few sample points.
816        if simplify(a, pool).value == simplify(b, pool).value {
817            return true;
818        }
819        let mut any = false;
820        for &xv in &[2.5f64, 7.0, 13.0] {
821            let mut env = HashMap::new();
822            env.insert(var, xv);
823            let (Some(va), Some(vb)) = (eval_interp(a, &env, pool), eval_interp(b, &env, pool))
824            else {
825                return false;
826            };
827            if (va - vb).abs() > 1e-9 * (1.0 + va.abs()) {
828                return false;
829            }
830            any = true;
831        }
832        any
833    }
834
835    /// Numeric value of a constant (var-independent) expression.
836    fn const_val(e: ExprId, pool: &ExprPool) -> Option<f64> {
837        eval_interp(e, &HashMap::new(), pool)
838    }
839
840    /// Numerically check f ~ Σ terms at a large x: residual small vs last term.
841    fn residual_small(f: ExprId, terms: &[ExprId], var: ExprId, pool: &ExprPool) -> bool {
842        let xv = 1.0e5;
843        let mut env = HashMap::new();
844        env.insert(var, xv);
845        let fv = eval_interp(f, &env, pool).unwrap();
846        let mut sum = 0.0;
847        let mut last = 0.0;
848        for &t in terms {
849            let v = eval_interp(t, &env, pool).unwrap();
850            sum += v;
851            last = v;
852        }
853        (fv - sum).abs() <= last.abs() * 8.0 + 1e-9
854    }
855
856    #[test]
857    fn rational_x_plus_1_over_x_minus_1() {
858        // (x+1)/(x-1) ~ 1 + 2/x + 2/x^2 + ...
859        let p = ExprPool::new();
860        let x = p.symbol("x", Domain::Positive);
861        let num = p.add(vec![x, p.integer(1)]);
862        let den = p.add(vec![x, p.integer(-1)]);
863        let f = p.mul(vec![num, p.pow(den, p.integer(-1))]);
864        let exp = asymptotic_expand(f, x, 4, &p).unwrap();
865        let terms = exp.term_exprs();
866        assert!(terms.len() >= 3, "got {} terms", terms.len());
867        // Leading term is 1.
868        assert_eq!(const_val(terms[0], &p), Some(1.0));
869        // 2/x
870        let two_over_x = p.mul(vec![p.integer(2), p.pow(x, p.integer(-1))]);
871        assert!(approx_eq_expr(terms[1], two_over_x, x, &p));
872        assert!(residual_small(f, &terms, x, &p));
873    }
874
875    #[test]
876    fn sqrt_x_squared_plus_one() {
877        // sqrt(x^2+1) ~ x + 1/(2x) - 1/(8x^3) + ...
878        let p = ExprPool::new();
879        let x = p.symbol("x", Domain::Positive);
880        let inside = p.add(vec![p.pow(x, p.integer(2)), p.integer(1)]);
881        let f = p.func("sqrt", vec![inside]);
882        let exp = asymptotic_expand(f, x, 3, &p).unwrap();
883        let terms = exp.term_exprs();
884        assert!(terms.len() >= 2, "got {} terms", terms.len());
885        assert!(approx_eq_expr(terms[0], x, x, &p));
886        // second term ~ 1/(2x)
887        let half_over_x = p.mul(vec![
888            p.rational(rug::Integer::from(1), rug::Integer::from(2)),
889            p.pow(x, p.integer(-1)),
890        ]);
891        assert!(approx_eq_expr(terms[1], half_over_x, x, &p));
892        assert!(residual_small(f, &terms, x, &p));
893    }
894
895    #[test]
896    fn x_sin_one_over_x() {
897        // x*sin(1/x) ~ 1 - 1/(6x^2) + ...
898        let p = ExprPool::new();
899        let x = p.symbol("x", Domain::Positive);
900        let inv = p.pow(x, p.integer(-1));
901        let f = p.mul(vec![x, p.func("sin", vec![inv])]);
902        let exp = asymptotic_expand(f, x, 3, &p).unwrap();
903        let terms = exp.term_exprs();
904        assert!(!terms.is_empty());
905        assert_eq!(const_val(terms[0], &p), Some(1.0));
906        assert!(residual_small(f, &terms, x, &p));
907    }
908
909    #[test]
910    fn sqrt_third_coefficient() {
911        // sqrt(x^2+1) third term is -1/(8 x^3): check the x^{-3} coefficient.
912        let p = ExprPool::new();
913        let x = p.symbol("x", Domain::Positive);
914        let inside = p.add(vec![p.pow(x, p.integer(2)), p.integer(1)]);
915        let f = p.func("sqrt", vec![inside]);
916        let exp = asymptotic_expand(f, x, 3, &p).unwrap();
917        let terms = exp.term_exprs();
918        assert!(terms.len() >= 3, "got {}", terms.len());
919        // Evaluate the x^{-3} term against the expected -1/8 · x^{-3}.
920        let mut env = HashMap::new();
921        env.insert(x, 2.0f64);
922        let third = eval_interp(terms[2], &env, &p).unwrap();
923        let expected = -1.0 / 8.0 * 2.0f64.powi(-3);
924        assert!((third - expected).abs() < 1e-12, "third={third}");
925    }
926
927    #[test]
928    fn oscillatory_declines() {
929        // sin(x) at +infinity has no power-scale asymptotic expansion; the
930        // o()-gate / scale detection must decline rather than fabricate terms.
931        let p = ExprPool::new();
932        let x = p.symbol("x", Domain::Positive);
933        let f = p.func("sin", vec![x]);
934        assert!(asymptotic_expand(f, x, 3, &p).is_err());
935    }
936
937    #[test]
938    fn exp_one_over_x_times_x() {
939        // e^{1/x} * x ~ x + 1 + 1/(2x) + ...
940        let p = ExprPool::new();
941        let x = p.symbol("x", Domain::Positive);
942        let inv = p.pow(x, p.integer(-1));
943        let f = p.mul(vec![p.func("exp", vec![inv]), x]);
944        let exp = asymptotic_expand(f, x, 3, &p).unwrap();
945        let terms = exp.term_exprs();
946        assert!(terms.len() >= 2, "got {}", terms.len());
947        assert!(approx_eq_expr(terms[0], x, x, &p));
948        assert_eq!(const_val(terms[1], &p), Some(1.0));
949        assert!(residual_small(f, &terms, x, &p));
950    }
951
952    #[test]
953    fn log_x_plus_one() {
954        // log(x+1) ~ log x + 1/x - 1/(2x^2) + ...
955        let p = ExprPool::new();
956        let x = p.symbol("x", Domain::Positive);
957        let arg = p.add(vec![x, p.integer(1)]);
958        let f = p.func("log", vec![arg]);
959        let exp = asymptotic_expand(f, x, 3, &p).unwrap();
960        let terms = exp.term_exprs();
961        assert!(terms.len() >= 2, "got {}", terms.len());
962        // leading term log(x)
963        let log_x = p.func("log", vec![x]);
964        assert_eq!(simplify(terms[0], &p).value, simplify(log_x, &p).value);
965        // 1/x term
966        let inv = p.pow(x, p.integer(-1));
967        assert!(approx_eq_expr(terms[1], inv, x, &p));
968        assert!(residual_small(f, &terms, x, &p));
969    }
970
971    #[test]
972    fn sqrt_x_plus_sqrt_x_leading() {
973        // sqrt(x + sqrt(x)) ~ sqrt(x) · sqrt(1 + 1/sqrt(x)); leading behaviour
974        // ~ sqrt(x). The inner sqrt(x) is a half-integer scale, so this is a
975        // Puiseux case for the integer-power core: we expect either the
976        // leading-scale peel to deliver sqrt(x), or an honest decline. Whatever
977        // is returned must pass the residual gate.
978        let p = ExprPool::new();
979        let x = p.symbol("x", Domain::Positive);
980        let inner = p.func("sqrt", vec![x]);
981        let arg = p.add(vec![x, inner]);
982        let f = p.func("sqrt", vec![arg]);
983        match asymptotic_expand(f, x, 2, &p) {
984            Ok(exp) => {
985                let terms = exp.term_exprs();
986                assert!(residual_small(f, &terms, x, &p));
987                // Leading term should behave like sqrt(x): ratio → 1 at large x.
988                let mut env = HashMap::new();
989                env.insert(x, 1.0e6f64);
990                let lead = eval_interp(terms[0], &env, &p).unwrap();
991                let sx = 1.0e3f64; // sqrt(1e6)
992                assert!((lead / sx - 1.0).abs() < 1e-2, "lead={lead}");
993            }
994            Err(_) => { /* honest decline acceptable (half-integer/Puiseux scale) */ }
995        }
996    }
997
998    #[test]
999    fn x_over_log_x_peels() {
1000        // x / log(x): a single dominant log factor with a power-expandable
1001        // cofactor x. Leading term ~ x / log(x).
1002        let p = ExprPool::new();
1003        let x = p.symbol("x", Domain::Positive);
1004        let logx = p.func("log", vec![x]);
1005        let f = p.mul(vec![x, p.pow(logx, p.integer(-1))]);
1006        match asymptotic_expand(f, x, 2, &p) {
1007            Ok(exp) => {
1008                let terms = exp.term_exprs();
1009                assert!(!terms.is_empty());
1010                assert!(residual_small(f, &terms, x, &p));
1011            }
1012            Err(_) => { /* acceptable: 1/log(x) cofactor is itself non-power */ }
1013        }
1014    }
1015
1016    #[test]
1017    fn invalid_term_count() {
1018        let p = ExprPool::new();
1019        let x = p.symbol("x", Domain::Positive);
1020        let err = asymptotic_expand(x, x, 0, &p).unwrap_err();
1021        assert!(matches!(err, AsymptoticError::InvalidTermCount));
1022    }
1023
1024    #[test]
1025    fn x_over_log_x_gate_is_honest() {
1026        // 1/(x log x): a genuine non-power scale; we must not fabricate an
1027        // unverified power-scale tail. Either an honest log-peel or a decline.
1028        let p = ExprPool::new();
1029        let x = p.symbol("x", Domain::Positive);
1030        let logx = p.func("log", vec![x]);
1031        let f = p.mul(vec![p.pow(x, p.integer(-1)), p.pow(logx, p.integer(-1))]);
1032        match asymptotic_expand(f, x, 3, &p) {
1033            Ok(exp) => {
1034                // If anything is returned it must pass the residual gate.
1035                let terms = exp.term_exprs();
1036                assert!(residual_small(f, &terms, x, &p));
1037            }
1038            Err(_) => { /* honest decline is acceptable */ }
1039        }
1040    }
1041}