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