Skip to main content

alkahest_cas/calculus/
series.rs

1//! Truncated Taylor / Laurent series with symbolic [`crate::kernel::ExprData::BigO`] remainder (V2-15).
2
3use crate::budget::BudgetError;
4use crate::diff::{diff, DiffError};
5use crate::flint::FlintPoly;
6use crate::kernel::{subs, Domain, ExprData, ExprId, ExprPool};
7use crate::poly::{RationalFunction, UniPoly};
8use crate::simplify::simplify;
9use std::cell::Cell;
10use std::collections::HashMap;
11use std::fmt;
12
13// ---------------------------------------------------------------------------
14// Public types
15// ---------------------------------------------------------------------------
16
17/// Result of [`series`] — truncated expansion plus big-O bound as one [`ExprId`].
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct Series(pub ExprId);
20
21impl Series {
22    pub fn expr(self) -> ExprId {
23        self.0
24    }
25}
26
27#[derive(Debug)]
28pub enum SeriesError {
29    /// Differentiation failed while forming Taylor coefficients.
30    Diff(DiffError),
31    /// The requested `order` is not one this call can expand to: it was `0`,
32    /// or the expansion ran past the work ceiling / an active
33    /// [`crate::budget`] before reaching it.
34    ///
35    /// The second reading is the carrier for a *refusal* — see
36    /// [`take_series_refusal`] for which of the two happened, and
37    /// [`SeriesRefusal`] for why the refusal cannot be its own variant.
38    InvalidOrder,
39}
40
41impl fmt::Display for SeriesError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            SeriesError::Diff(e) => write!(f, "{e}"),
45            SeriesError::InvalidOrder => write!(
46                f,
47                "series order must be >= 1 and reachable: the expansion is not \
48                 available at the order requested"
49            ),
50        }
51    }
52}
53
54impl std::error::Error for SeriesError {
55    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
56        match self {
57            SeriesError::Diff(e) => Some(e),
58            SeriesError::InvalidOrder => None,
59        }
60    }
61}
62
63impl crate::errors::AlkahestError for SeriesError {
64    fn code(&self) -> &'static str {
65        match self {
66            SeriesError::Diff(_) => "E-SERIES-001",
67            SeriesError::InvalidOrder => "E-SERIES-002",
68        }
69    }
70
71    fn remediation(&self) -> Option<&'static str> {
72        match self {
73            SeriesError::Diff(_) => {
74                Some("ensure all functions are registered primitives with differentiation rules")
75            }
76            SeriesError::InvalidOrder => Some(
77                "pass order >= 1 (exclusive truncation degree in x); if the order was \
78                 already positive the expansion exceeded the work ceiling — ask for a \
79                 lower order, or simplify the expression so its derivatives close",
80            ),
81        }
82    }
83}
84
85impl From<DiffError> for SeriesError {
86    fn from(e: DiffError) -> Self {
87        SeriesError::Diff(e)
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Entry point
93// ---------------------------------------------------------------------------
94
95/// Truncated Taylor or Laurent expansion of `expr` in `var` about `point`.
96///
97/// Let `h = var - point`. The returned expression has the shape
98/// `⋯ + O(h^k)` where `k = order` for analytic series (`valuation ≥ 0`), and
99/// `k = 1` when a polar term (`valuation < 0`) is present — matching the
100/// Laurent examples in the roadmap (`1/x` about `0` gives `x⁻¹ + O(x)`).
101///
102/// The `order` parameter matches the Taylor convention used in the roadmap:
103/// include powers `h^e` with `valuation ≤ e < order` when `valuation ≥ 0`, and
104/// when `valuation < 0` include the polar tail using `order` Taylor coefficients
105/// of the analytic factor `h^{-valuation} · f`.
106///
107/// # Termination
108///
109/// The coefficient loop is bounded: it honours [`crate::budget`] (wall clock,
110/// steps, [`crate::budget::request_cancel`]) and, with no budget active, an
111/// internal work ceiling ([`MAX_SERIES_POOL_GROWTH`]). Coefficients are formed
112/// by repeated differentiation *without* re-simplifying, so an expression whose
113/// derivatives do not close — `√(t⁻² + t⁻¹)` is the standard example — grows by
114/// a constant factor per coefficient and order 32 is not slow but unreachable.
115///
116/// Running out of room is reported as **`Err(SeriesError::InvalidOrder)` with a
117/// [`take_series_refusal`] pending**, never as a shorter series: a truncated
118/// expansion still labelled `O(hᵒʳᵈᵉʳ)` would be a false statement about the
119/// remainder, and that is a lie where a refusal is merely a limitation.
120pub fn series(
121    expr: ExprId,
122    var: ExprId,
123    point: ExprId,
124    order: u32,
125    pool: &ExprPool,
126) -> Result<Series, SeriesError> {
127    let frame = enter_series_frame();
128    // The ceiling is what makes the loop stoppable at all: `local_expansion` is
129    // one uninterruptible call from here, so there is nowhere else to put a
130    // checkpoint. Unlike `limit`'s, this one refuses instead of settling for
131    // the prefix it managed to compute.
132    let _ceiling = enter_coeff_ceiling(pool.len().saturating_add(MAX_SERIES_POOL_GROWTH));
133
134    let LocalExpansion {
135        valuation,
136        coeffs,
137        h_expr,
138    } = local_expansion(expr, var, point, order, pool)?;
139
140    if frame.refusal_pending() {
141        return Err(SeriesError::InvalidOrder);
142    }
143
144    Ok(assemble_series(&coeffs, valuation, h_expr, order, pool))
145}
146
147// ---------------------------------------------------------------------------
148// Internals
149// ---------------------------------------------------------------------------
150
151/// Local Laurent / Taylor data about `point`: `expr = ∑ᵢ coeffᵢ · h^{valuation+i}` up to truncation.
152///
153/// `h` is `var - point`, or bare `var` when `point` is the integer zero (matching [`series`]).
154#[derive(Clone, Debug)]
155pub(crate) struct LocalExpansion {
156    pub valuation: i32,
157    pub coeffs: Vec<ExprId>,
158    pub h_expr: ExprId,
159}
160
161pub(crate) fn local_expansion(
162    expr: ExprId,
163    var: ExprId,
164    point: ExprId,
165    order: u32,
166    pool: &ExprPool,
167) -> Result<LocalExpansion, SeriesError> {
168    if order == 0 {
169        return Err(SeriesError::InvalidOrder);
170    }
171
172    let xi = pool.symbol("__sxp", Domain::Real);
173    let mut map = HashMap::new();
174    map.insert(var, pool.add(vec![point, xi]));
175    let shifted = subs(expr, &map, pool);
176
177    let h_expr = expansion_increment(pool, var, point);
178
179    expansion_matched_laurent(shifted, xi, h_expr, order, pool)
180}
181
182fn factorial_u32(n: u32) -> rug::Integer {
183    let mut r = rug::Integer::from(1);
184    for i in 2..=n {
185        r *= i;
186    }
187    r
188}
189
190fn expansion_increment(pool: &ExprPool, var: ExprId, point: ExprId) -> ExprId {
191    match pool.get(point) {
192        ExprData::Integer(n) if n.0 == 0 => var,
193        _ => pool.add(vec![var, pool.mul(vec![pool.integer(-1_i32), point])]),
194    }
195}
196
197fn laurent_big_o_pow(valuation: i32, order: u32) -> i64 {
198    if valuation < 0 {
199        1
200    } else {
201        order as i64
202    }
203}
204
205fn is_structural_zero(id: ExprId, pool: &ExprPool) -> bool {
206    matches!(pool.get(id), ExprData::Integer(n) if n.0 == 0)
207}
208
209fn collect_atom_factors(expr: ExprId, pool: &ExprPool) -> Option<(Vec<ExprId>, Vec<ExprId>)> {
210    match pool.get(expr) {
211        ExprData::Pow { base, exp } => {
212            let n = pool.with(exp, |d| match d {
213                ExprData::Integer(i) => Some(i.0.clone()),
214                _ => None,
215            })?;
216            if n > 0 {
217                Some((vec![expr], vec![]))
218            } else if n < 0 {
219                let mag = (-n).to_u32()?;
220                let pos_exp = pool.integer(mag as i64);
221                Some((vec![], vec![pool.pow(base, pos_exp)]))
222            } else {
223                Some((vec![pool.integer(1_i32)], vec![]))
224            }
225        }
226        ExprData::Integer(_)
227        | ExprData::Rational(_)
228        | ExprData::Float(_)
229        | ExprData::Symbol { .. }
230        | ExprData::Func { .. } => Some((vec![expr], vec![])),
231        ExprData::Add(_)
232        | ExprData::Mul(_)
233        | ExprData::Piecewise { .. }
234        | ExprData::Predicate { .. }
235        | ExprData::Forall { .. }
236        | ExprData::Exists { .. }
237        | ExprData::RootSum { .. }
238        | ExprData::BigO(_) => None,
239    }
240}
241
242fn collect_term_factors(expr: ExprId, pool: &ExprPool) -> Option<(Vec<ExprId>, Vec<ExprId>)> {
243    match pool.get(expr) {
244        ExprData::Mul(args) => {
245            let mut nums = Vec::new();
246            let mut dens = Vec::new();
247            for &a in &args {
248                let (n, d) = collect_atom_factors(a, pool)?;
249                nums.extend(n);
250                dens.extend(d);
251            }
252            Some((nums, dens))
253        }
254        _ => collect_atom_factors(expr, pool),
255    }
256}
257
258fn product_sorted(pool: &ExprPool, factors: Vec<ExprId>) -> ExprId {
259    match factors.len() {
260        0 => pool.integer(1_i32),
261        1 => factors[0],
262        _ => pool.mul(factors),
263    }
264}
265
266fn unipoly_valuation(p: &UniPoly) -> Option<u32> {
267    for (i, c) in p.coefficients().into_iter().enumerate() {
268        if c != 0 {
269            return Some(i as u32);
270        }
271    }
272    None
273}
274
275fn unipoly_strip_low(p: &UniPoly, k: u32) -> UniPoly {
276    let coeffs: Vec<rug::Integer> = p.coefficients().into_iter().skip(k as usize).collect();
277    UniPoly {
278        var: p.var,
279        coeffs: FlintPoly::from_rug_coefficients(&coeffs),
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Coefficient-loop ceiling
285// ---------------------------------------------------------------------------
286
287/// How many *new* expression nodes one top-level [`series`] call may intern
288/// before it refuses.
289///
290/// Measured rather than guessed, with an order of magnitude of headroom: the
291/// heaviest expansions in the Rust and Python suites intern a few thousand nodes
292/// (`sin` at order 24: 125; `√(1+x)` at order 24: 677; `tan` at order 16: 1 564;
293/// `log(1+x)/(1−x)` at order 20: 4 579), while `√(t⁻² + t⁻¹)` at order 32 doubles
294/// per coefficient and reaches this ceiling in a fraction of a second.
295///
296/// Counting interned nodes rather than iterations catches the pathology directly
297/// (it is *size* that explodes, not the iteration count), costs `O(1)` per check
298/// — [`ExprPool::len`] is a lock-free counter — and is monotone, so no path can
299/// evade it.
300pub const MAX_SERIES_POOL_GROWTH: usize = 50_000;
301
302thread_local! {
303    /// Absolute `pool.len()` ceiling for [`taylor_coefficients`], or `None` for
304    /// "compute every coefficient that was asked for".
305    static COEFF_POOL_CEILING: Cell<Option<usize>> = const { Cell::new(None) };
306    /// `true` while a [`series`] call is on the stack, which is the only
307    /// context in which a truncated coefficient loop is a refusal rather than
308    /// the requested behaviour.
309    static IN_SERIES: Cell<bool> = const { Cell::new(false) };
310    /// The refusal behind the [`SeriesError::InvalidOrder`] the current thread
311    /// is about to return, if that error is a work-ceiling trip rather than a
312    /// zero `order`.
313    static LAST_REFUSAL: Cell<Option<SeriesRefusal>> = const { Cell::new(None) };
314}
315
316/// A [`series`] call that could not reach the order it was asked for.
317///
318/// # Why this is not an error variant
319///
320/// [`SeriesError`] is a public *exhaustive* enum, so growing it a `Truncated`
321/// variant is a major semver break — and so is marking it `#[non_exhaustive]`
322/// to allow it later. A correctness fix inside a patch release cannot spend a
323/// major version, so the refusal travels out of band: [`series`] returns
324/// [`SeriesError::InvalidOrder`], whose reworded text states exactly the
325/// disjunction that is known ("the order is not one this call can expand to"),
326/// and the real cause is recorded here for [`take_series_refusal`] to hand to
327/// the bindings, which raise its own `E-SERIES-003` (or the `E-BUDGET-*` of the
328/// budget that tripped).
329///
330/// This is the pattern [`crate::calculus::limits::last_budget_trip`] uses for
331/// budget trips inside `LimitError::DepthExceeded`, and
332/// [`crate::matrix::take_zero_test_refusal`] for undecided zero tests inside
333/// `MatrixError::SingularMatrix`.
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335pub struct SeriesRefusal {
336    requested: u32,
337    computed: u32,
338    budget: Option<BudgetError>,
339}
340
341impl SeriesRefusal {
342    /// Number of Taylor coefficients that were asked for.
343    pub fn requested_coefficients(&self) -> u32 {
344        self.requested
345    }
346
347    /// Number of Taylor coefficients that were formed before the loop stopped.
348    ///
349    /// Deliberately *not* returned as a series: `assemble_series` would label it
350    /// `O(h^requested)`, which is a claim about a remainder nobody bounded.
351    pub fn computed_coefficients(&self) -> u32 {
352        self.computed
353    }
354
355    /// The [`BudgetError`] that stopped this expansion, or `None` when it was
356    /// the internal work ceiling.
357    pub fn budget(&self) -> Option<BudgetError> {
358        self.budget
359    }
360}
361
362impl fmt::Display for SeriesRefusal {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        write!(
365            f,
366            "series expansion stopped after {} of {} Taylor coefficients ({}); \
367             refusing to return a shorter series labelled with the requested \
368             order, which would understate the O(.) remainder",
369            self.computed,
370            self.requested,
371            match self.budget {
372                Some(b) => format!("budget: {b}"),
373                None => "internal work ceiling".to_string(),
374            }
375        )
376    }
377}
378
379impl std::error::Error for SeriesRefusal {}
380
381impl crate::errors::AlkahestError for SeriesRefusal {
382    fn code(&self) -> &'static str {
383        "E-SERIES-003"
384    }
385
386    fn remediation(&self) -> Option<&'static str> {
387        Some(
388            "ask for a lower order, raise the budget, or rewrite the expression so its \
389             repeated derivatives close (nested radicals grow by a constant factor per \
390             coefficient)",
391        )
392    }
393}
394
395/// RAII marker for the outermost [`series`] frame on this thread.
396pub(crate) struct SeriesFrame {
397    outermost: bool,
398}
399
400impl SeriesFrame {
401    /// Did the coefficient loop stop early during this call?
402    fn refusal_pending(&self) -> bool {
403        LAST_REFUSAL.with(|c| c.get().is_some())
404    }
405}
406
407impl Drop for SeriesFrame {
408    fn drop(&mut self) {
409        if self.outermost {
410            IN_SERIES.with(|c| c.set(false));
411        }
412    }
413}
414
415/// Enter a [`series`] frame, clearing any refusal left by an earlier call so a
416/// pending one always describes the call that just returned.
417fn enter_series_frame() -> SeriesFrame {
418    LAST_REFUSAL.with(|c| c.set(None));
419    IN_SERIES.with(|c| {
420        let already = c.get();
421        c.set(true);
422        SeriesFrame {
423            outermost: !already,
424        }
425    })
426}
427
428/// Take the refusal behind the [`SeriesError::InvalidOrder`] that just came
429/// back, if there was one.
430///
431/// `Some` means the requested order was positive and simply out of reach — the
432/// work ceiling or an active [`crate::budget`] stopped the coefficient loop.
433/// `None` means the variant means what it has always meant: `order == 0`.
434///
435/// Consuming, so one refusal is reported once and cannot leak into a later
436/// unrelated error. Thread-local, like the ceiling itself.
437pub fn take_series_refusal() -> Option<SeriesRefusal> {
438    LAST_REFUSAL.with(|c| c.take())
439}
440
441/// RAII installer for the [`taylor_coefficients`] ceiling; restores the
442/// previous value on drop, including on panic-unwind.
443pub(crate) struct CoeffCeiling(Option<usize>);
444
445impl Drop for CoeffCeiling {
446    fn drop(&mut self) {
447        COEFF_POOL_CEILING.with(|c| c.set(self.0));
448    }
449}
450
451/// Stop [`taylor_coefficients`] early once the pool has grown past `ceiling`,
452/// returning the coefficients computed so far.
453///
454/// [`crate::calculus::limits`] scans for the first nonzero coefficient, so a
455/// short prefix is either enough to answer or an honest "no answer at this
456/// order", never a wrong answer, and it simply uses what it got. [`series`]
457/// installs a ceiling too — it has to, or the loop is unbounded — but it treats
458/// a short prefix as a **refusal** ([`take_series_refusal`]): returning it would
459/// understate the `O(·)` term, which would be a lie rather than a limitation.
460///
461/// Successive Taylor coefficients are formed by differentiating *without*
462/// re-simplifying, so for expressions whose derivatives do not close (nested
463/// radicals) each one is a constant factor larger than the last. Without this
464/// the loop is unbounded in both time and memory, and — being a single call —
465/// gives the caller nowhere to place a cancellation checkpoint.
466pub(crate) fn enter_coeff_ceiling(ceiling: usize) -> CoeffCeiling {
467    COEFF_POOL_CEILING.with(|c| {
468        let prev = c.get();
469        c.set(Some(ceiling));
470        CoeffCeiling(prev)
471    })
472}
473
474/// `true` when the installed ceiling has been reached, or the ambient
475/// [`crate::budget`] has been exhausted / cancelled.
476fn coeff_loop_should_stop(pool: &ExprPool) -> bool {
477    match COEFF_POOL_CEILING.with(|c| c.get()) {
478        Some(ceiling) => pool.len() > ceiling || crate::budget::check().is_err(),
479        None => false,
480    }
481}
482
483fn taylor_coefficients(
484    mut cur: ExprId,
485    xi: ExprId,
486    num: u32,
487    pool: &ExprPool,
488) -> Result<Vec<ExprId>, SeriesError> {
489    let mut mapping = HashMap::new();
490    mapping.insert(xi, pool.integer(0_i32));
491    let mut out = Vec::with_capacity(num as usize);
492    for k in 0..num {
493        if k > 0 && coeff_loop_should_stop(pool) {
494            // Inside a `series` call this prefix is not an answer — record why,
495            // for `series` to turn into a refusal. Every other caller wants the
496            // prefix, so nothing is recorded for them and no stale refusal is
497            // left behind for the next `take_series_refusal`.
498            if IN_SERIES.with(|c| c.get()) {
499                let refusal = SeriesRefusal {
500                    requested: num,
501                    computed: k,
502                    budget: crate::budget::check().err(),
503                };
504                LAST_REFUSAL.with(|c| c.set(Some(refusal)));
505            }
506            break;
507        }
508        let ev = subs(cur, &mapping, pool);
509        let simp = simplify(ev, pool).value;
510        let fc = factorial_u32(k);
511        let inv_fact = pool.rational(rug::Integer::from(1), fc);
512        let coeff = simplify(pool.mul(vec![simp, inv_fact]), pool).value;
513        out.push(coeff);
514        if k + 1 < num {
515            cur = diff(cur, xi, pool)?.value;
516        }
517    }
518    Ok(out)
519}
520
521fn assemble_series(
522    coeffs: &[ExprId],
523    valuation: i32,
524    h_expr: ExprId,
525    order: u32,
526    pool: &ExprPool,
527) -> Series {
528    let mut terms = Vec::new();
529    for (k, coeff) in coeffs.iter().enumerate() {
530        if is_structural_zero(*coeff, pool) {
531            continue;
532        }
533        let exp = valuation + k as i32;
534        let pow_term = if exp == 0 {
535            pool.integer(1_i32)
536        } else if exp == 1 {
537            h_expr
538        } else {
539            pool.pow(h_expr, pool.integer(exp as i64))
540        };
541        terms.push(pool.mul(vec![*coeff, pow_term]));
542    }
543    let big_o_pow = laurent_big_o_pow(valuation, order);
544    let o_term = pool.big_o(pool.pow(h_expr, pool.integer(big_o_pow)));
545    terms.push(o_term);
546    Series(pool.add(terms))
547}
548
549fn expansion_matched_laurent(
550    shifted: ExprId,
551    xi: ExprId,
552    h_expr: ExprId,
553    order: u32,
554    pool: &ExprPool,
555) -> Result<LocalExpansion, SeriesError> {
556    let (nums, dens) = match collect_term_factors(shifted, pool) {
557        Some(p) => p,
558        None => {
559            let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
560            return Ok(LocalExpansion {
561                valuation: 0,
562                coeffs,
563                h_expr,
564            });
565        }
566    };
567
568    let n_expr = product_sorted(pool, nums);
569    let d_expr = product_sorted(pool, dens);
570
571    let rf = match RationalFunction::from_symbolic(n_expr, d_expr, vec![xi], pool) {
572        Ok(r) => r,
573        Err(_) => {
574            let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
575            return Ok(LocalExpansion {
576                valuation: 0,
577                coeffs,
578                h_expr,
579            });
580        }
581    };
582
583    if rf.numer.is_zero() {
584        return Ok(LocalExpansion {
585            valuation: 0,
586            coeffs: vec![pool.integer(0_i32)],
587            h_expr,
588        });
589    }
590
591    let n_uni = match UniPoly::from_symbolic(rf.numer.to_expr(pool), xi, pool) {
592        Ok(u) => u,
593        Err(_) => {
594            let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
595            return Ok(LocalExpansion {
596                valuation: 0,
597                coeffs,
598                h_expr,
599            });
600        }
601    };
602    let d_uni = match UniPoly::from_symbolic(rf.denom.to_expr(pool), xi, pool) {
603        Ok(u) => u,
604        Err(_) => {
605            let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
606            return Ok(LocalExpansion {
607                valuation: 0,
608                coeffs,
609                h_expr,
610            });
611        }
612    };
613
614    let vn = match unipoly_valuation(&n_uni) {
615        Some(v) => v,
616        None => {
617            return Ok(LocalExpansion {
618                valuation: 0,
619                coeffs: vec![pool.integer(0_i32)],
620                h_expr,
621            });
622        }
623    };
624    let vd = match unipoly_valuation(&d_uni) {
625        Some(v) => v,
626        None => {
627            let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
628            return Ok(LocalExpansion {
629                valuation: 0,
630                coeffs,
631                h_expr,
632            });
633        }
634    };
635
636    let valuation = vn as i32 - vd as i32;
637    let n0 = unipoly_strip_low(&n_uni, vn);
638    let d0 = unipoly_strip_low(&d_uni, vd);
639
640    let d0c = d0.coefficients();
641    if d0c.is_empty() || d0c[0] == 0 {
642        let coeffs = taylor_coefficients(shifted, xi, order, pool)?;
643        return Ok(LocalExpansion {
644            valuation: 0,
645            coeffs,
646            h_expr,
647        });
648    }
649
650    let n0_e = n0.to_symbolic_expr(pool);
651    let d0_e = d0.to_symbolic_expr(pool);
652    let inv_d = pool.pow(d0_e, pool.integer(-1_i32));
653    let g = simplify(pool.mul(vec![n0_e, inv_d]), pool).value;
654
655    let num_taylor: u32 = if valuation < 0 {
656        order
657    } else {
658        (order as i32 - valuation).max(0) as u32
659    };
660
661    if num_taylor == 0 {
662        return Ok(LocalExpansion {
663            valuation,
664            coeffs: Vec::new(),
665            h_expr,
666        });
667    }
668
669    let coeffs = taylor_coefficients(g, xi, num_taylor, pool)?;
670    Ok(LocalExpansion {
671        valuation,
672        coeffs,
673        h_expr,
674    })
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::kernel::{Domain, ExprData};
681
682    fn contains_big_o(id: ExprId, pool: &ExprPool) -> bool {
683        match pool.get(id) {
684            ExprData::BigO(_) => true,
685            ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().any(|e| contains_big_o(*e, pool)),
686            ExprData::Pow { base, exp } => contains_big_o(base, pool) || contains_big_o(exp, pool),
687            ExprData::Func { args, .. } => args.iter().any(|e| contains_big_o(*e, pool)),
688            _ => false,
689        }
690    }
691
692    #[test]
693    fn series_cos_about_zero_has_big_o() {
694        let p = ExprPool::new();
695        let x = p.symbol("x", Domain::Real);
696        let z = p.integer(0);
697        let cx = p.func("cos", vec![x]);
698        let s = series(cx, x, z, 6, &p).unwrap();
699        assert!(contains_big_o(s.expr(), &p));
700    }
701
702    #[test]
703    fn series_inv_x_laurent_has_big_o() {
704        let p = ExprPool::new();
705        let x = p.symbol("x", Domain::Real);
706        let z = p.integer(0);
707        let ix = p.pow(x, p.integer(-1));
708        let s = series(ix, x, z, 4, &p).unwrap();
709        assert!(contains_big_o(s.expr(), &p));
710    }
711
712    /// `√(t⁻² + t⁻¹)` at order 32 is the runaway shape: each coefficient is
713    /// formed by differentiating the previous one without re-simplifying, and a
714    /// nested radical's derivatives grow by a constant factor, so the loop is
715    /// unfinishable rather than slow (order 13 already takes 0.15 s and the cost
716    /// doubles per order).
717    ///
718    /// The refusal is the assertion. A *short* series would be worse than the
719    /// hang it replaces: `O(t^32)` on nine computed coefficients is a false
720    /// statement about the remainder, and unlike a timeout the caller has no way
721    /// to notice. This test also passes trivially if the expansion is ever made
722    /// to terminate honestly at the full order — see the `is_ok` arm.
723    #[test]
724    fn series_refuses_rather_than_truncating_a_runaway_radical() {
725        use crate::errors::AlkahestError;
726        let p = ExprPool::new();
727        let t = p.symbol("t", Domain::Real);
728        let inner = p.add(vec![p.pow(t, p.integer(-2)), p.pow(t, p.integer(-1))]);
729        let ex = p.func("sqrt", vec![inner]);
730
731        match series(ex, t, p.integer(0), 32, &p) {
732            Ok(_) => {
733                // A future fast path that really reaches order 32 is welcome;
734                // it must not leave a refusal behind.
735                assert_eq!(take_series_refusal(), None);
736            }
737            Err(e) => {
738                assert!(matches!(e, SeriesError::InvalidOrder), "{e:?}");
739                let refusal = take_series_refusal().expect("work-ceiling refusal recorded");
740                assert_eq!(refusal.code(), "E-SERIES-003");
741                assert_eq!(refusal.budget(), None, "no budget was active");
742                assert!(
743                    refusal.computed_coefficients() < refusal.requested_coefficients(),
744                    "{refusal}"
745                );
746            }
747        }
748    }
749
750    /// The carrier variant keeps its original meaning: `order == 0` is a user
751    /// error, not a refusal, and must not leave a refusal pending for the
752    /// bindings to mis-report as `E-SERIES-003`.
753    #[test]
754    fn order_zero_is_a_user_error_not_a_refusal() {
755        let p = ExprPool::new();
756        let x = p.symbol("x", Domain::Real);
757        let cx = p.func("cos", vec![x]);
758        let err = series(cx, x, p.integer(0), 0, &p).unwrap_err();
759        assert!(matches!(err, SeriesError::InvalidOrder), "{err:?}");
760        assert_eq!(take_series_refusal(), None);
761    }
762
763    /// A budget trip is attributed to the budget, so a binding raises
764    /// `E-BUDGET-*` rather than "this order is unreachable".
765    #[test]
766    fn budget_stops_a_series_and_is_attributed() {
767        use crate::budget::{self, Budget, BudgetError};
768        let p = ExprPool::new();
769        let t = p.symbol("t", Domain::Real);
770        let inner = p.add(vec![p.pow(t, p.integer(-2)), p.pow(t, p.integer(-1))]);
771        let ex = p.func("sqrt", vec![inner]);
772
773        let _guard = budget::enter(Budget::new().with_max_steps(3));
774        let err = series(ex, t, p.integer(0), 32, &p).unwrap_err();
775        assert!(matches!(err, SeriesError::InvalidOrder), "{err:?}");
776        let refusal = take_series_refusal().expect("budget refusal recorded");
777        assert!(
778            matches!(refusal.budget(), Some(BudgetError::Steps { .. })),
779            "{refusal}"
780        );
781    }
782
783    /// The ceiling must not cost coverage: an ordinary high-order expansion of
784    /// a function whose derivatives close still returns, and leaves no refusal.
785    #[test]
786    fn ordinary_high_order_expansion_is_unaffected() {
787        let p = ExprPool::new();
788        let x = p.symbol("x", Domain::Real);
789        let sx = p.func("sin", vec![x]);
790        let s = series(sx, x, p.integer(0), 24, &p).unwrap();
791        assert!(contains_big_o(s.expr(), &p));
792        assert_eq!(take_series_refusal(), None);
793    }
794}