Skip to main content

alkahest_cas/calculus/
limits.rs

1//! Symbolic limits towards finite points or ±∞ via local expansions (`Series`),
2//! L'Hôpital iterations, algebraic transforms, and the Gruntz comparability-graph
3//! algorithm for exp-log combinations (V2-16/V2-17).
4
5use crate::budget::BudgetError;
6use crate::calculus::asymptotic::regularize_at_zero;
7use crate::calculus::gruntz::try_gruntz;
8use crate::calculus::series::{enter_coeff_ceiling, local_expansion, LocalExpansion};
9use crate::diff::{diff, DiffError};
10use crate::kernel::pool::POS_INFINITY_SYMBOL;
11use crate::kernel::{subs, ExprData, ExprId, ExprPool};
12use crate::poly::{poly_normal, RationalFunction};
13use crate::simplify::{simplify, simplify_expanded};
14use crate::SeriesError;
15use std::cell::Cell;
16use std::collections::HashMap;
17use std::fmt;
18
19/// Approach direction toward `point` (real-axis ordering).
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
21pub enum LimitDirection {
22    /// Ordinary two-sided limit.
23    Bidirectional,
24    /// Limits with `var > point` (approach from the right on the usual number line picture).
25    Plus,
26    /// Limits with `var < point`.
27    Minus,
28}
29
30#[derive(Debug)]
31pub enum LimitError {
32    /// Sub-problem rejected by [`mod@crate::calculus::series`].
33    Series(SeriesError),
34    /// Derivative unavailable for L'Hôpital.
35    Diff(DiffError),
36    /// Odd-order pole requires a one-sided direction.
37    NeedsOneSided,
38    /// The search ran out of room: the L'Hôpital / recursion depth cap, the
39    /// internal work ceiling ([`limit`]'s termination guard), or the ambient
40    /// [`crate::budget`] — see [`last_budget_trip`] to tell a budget trip from
41    /// the engine's own limits.
42    DepthExceeded,
43    /// No implemented rule applies (non-comparable growth, oscillation, …).
44    Unsupported,
45}
46
47impl fmt::Display for LimitError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            LimitError::Series(e) => write!(f, "{e}"),
51            LimitError::Diff(e) => write!(f, "{e}"),
52            LimitError::NeedsOneSided => {
53                write!(
54                    f,
55                    "two-sided limit undefined at this pole; pass direction Plus or Minus"
56                )
57            }
58            LimitError::DepthExceeded => write!(f, "limit refinement depth exceeded"),
59            LimitError::Unsupported => write!(f, "limit could not be computed with current rules"),
60        }
61    }
62}
63
64impl std::error::Error for LimitError {
65    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
66        match self {
67            LimitError::Series(e) => Some(e),
68            LimitError::Diff(e) => Some(e),
69            _ => None,
70        }
71    }
72}
73
74impl crate::errors::AlkahestError for LimitError {
75    fn code(&self) -> &'static str {
76        match self {
77            LimitError::Series(_) => "E-LIMIT-001",
78            LimitError::Diff(_) => "E-LIMIT-002",
79            LimitError::NeedsOneSided => "E-LIMIT-003",
80            LimitError::DepthExceeded => "E-LIMIT-004",
81            LimitError::Unsupported => "E-LIMIT-005",
82        }
83    }
84
85    fn remediation(&self) -> Option<&'static str> {
86        Some(match self {
87            LimitError::Series(_) => {
88                "increase truncation order indirectly by simplifying the expression, or rewrite using standard limits"
89            }
90            LimitError::Diff(_) => {
91                "ensure primitives have differentiation rules, or simplify before taking the limit"
92            }
93            LimitError::NeedsOneSided => "use LimitDirection::Plus or Minus matching the desired one-sided approach",
94            LimitError::DepthExceeded => {
95                "try manual algebra (quotient form, cancellations) or split into simpler sub-expressions"
96            }
97            LimitError::Unsupported => {
98                "limit could not be computed — try manual algebra, or the expression may involve oscillation or non-comparable growth not yet handled"
99            }
100        })
101    }
102}
103
104impl From<SeriesError> for LimitError {
105    fn from(e: SeriesError) -> Self {
106        LimitError::Series(e)
107    }
108}
109
110impl From<DiffError> for LimitError {
111    fn from(e: DiffError) -> Self {
112        LimitError::Diff(e)
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Termination guard — cooperative budget checkpoints plus an internal work
118// ceiling, so no search path in this engine can run unboundedly.
119// ---------------------------------------------------------------------------
120
121/// How many *new* expression nodes one top-level [`limit`] call may intern
122/// before the engine gives up with [`LimitError::DepthExceeded`].
123///
124/// The pathological shape this bounds is repeated symbolic differentiation:
125/// [`crate::calculus::series`] builds Taylor coefficients by differentiating
126/// without re-simplifying, so an expression whose derivatives do not close
127/// (nested radicals, in particular) grows by a constant factor per
128/// coefficient. Thirty-two coefficients of `√(x²+x)` rewritten at `t → 0⁺`
129/// is not a slow computation, it is an unfinishable one — the loop ran for
130/// hours with no output. Counting interned nodes rather than iterations
131/// catches that directly, is `O(1)` per checkpoint ([`ExprPool::len`] is a
132/// lock-free counter), and is monotone, so no path can evade it.
133///
134/// Sized with an order of magnitude of headroom: the heaviest limit in the
135/// Rust and Python suites interns ~9k nodes (`x·sin(1/x)` at 0; most are under
136/// 300), while a runaway radical expansion reaches this ceiling in a few
137/// hundred milliseconds.
138const MAX_LIMIT_POOL_GROWTH: usize = 100_000;
139
140thread_local! {
141    /// `pool.len()` when the outermost [`limit`] call on this thread started.
142    /// `None` outside any `limit` call.
143    static WORK_BASELINE: Cell<Option<usize>> = const { Cell::new(None) };
144    /// The [`BudgetError`] that tripped the most recent outermost [`limit`]
145    /// call, if any — see [`last_budget_trip`].
146    static BUDGET_TRIP: Cell<Option<BudgetError>> = const { Cell::new(None) };
147}
148
149/// RAII marker for the outermost [`limit`] frame on this thread.
150///
151/// [`limit`] re-enters itself (Gruntz sub-limits, growth comparisons), and the
152/// work ceiling must bound the *whole* call rather than restart at every
153/// re-entry, so only the outermost frame installs and clears the baseline.
154struct WorkFrame {
155    outermost: bool,
156}
157
158impl Drop for WorkFrame {
159    fn drop(&mut self) {
160        if self.outermost {
161            WORK_BASELINE.with(|c| c.set(None));
162        }
163    }
164}
165
166fn enter_work_frame(pool: &ExprPool) -> WorkFrame {
167    WORK_BASELINE.with(|c| {
168        if c.get().is_some() {
169            return WorkFrame { outermost: false };
170        }
171        c.set(Some(pool.len()));
172        WorkFrame { outermost: true }
173    })
174}
175
176/// `true` once this `limit` call has interned more than
177/// [`MAX_LIMIT_POOL_GROWTH`] nodes.
178fn work_exhausted(pool: &ExprPool) -> bool {
179    WORK_BASELINE.with(|c| match c.get() {
180        Some(base) => pool.len().saturating_sub(base) > MAX_LIMIT_POOL_GROWTH,
181        None => false,
182    })
183}
184
185/// The absolute `pool.len()` ceiling for the current `limit` call, for handing
186/// to [`enter_coeff_ceiling`] so the Taylor-coefficient loop stops at the same
187/// place this engine's own checkpoints do.
188fn coeff_ceiling(pool: &ExprPool) -> usize {
189    WORK_BASELINE.with(|c| {
190        c.get()
191            .unwrap_or_else(|| pool.len())
192            .saturating_add(MAX_LIMIT_POOL_GROWTH)
193    })
194}
195
196/// Cooperative checkpoint for the limit engine: honours [`crate::budget`] and
197/// the internal work ceiling.
198///
199/// Placed on every path that can iterate or recurse — see [`limit_inner`],
200/// [`canonical_polynomial_quotient_in_var`], [`try_expansion_limit`],
201/// [`try_regularized_infinity_limit`] and [`crate::calculus::gruntz`].
202pub(crate) fn checkpoint(pool: &ExprPool) -> Result<(), LimitError> {
203    if let Err(e) = crate::budget::check() {
204        BUDGET_TRIP.with(|c| c.set(Some(e)));
205        return Err(LimitError::DepthExceeded);
206    }
207    if work_exhausted(pool) {
208        return Err(LimitError::DepthExceeded);
209    }
210    Ok(())
211}
212
213/// The [`BudgetError`] that stopped the most recent outermost [`limit`] call on
214/// this thread, or `None` if that call was not stopped by a budget.
215///
216/// [`LimitError`] is an exhaustive public enum, so it cannot grow a `Budget`
217/// variant without a major semver break (the same constraint
218/// [`mod@crate::integrate`] works around by encoding budget trips inside
219/// `NotImplemented`). Limit budget trips are reported as
220/// [`LimitError::DepthExceeded`] — an honest "gave up" — and this function
221/// tells a caller *why* it gave up, so bindings can raise a dedicated
222/// budget-exceeded error carrying the `E-BUDGET-*` code.
223///
224/// Cleared at the start of every outermost `limit` call, so it only ever
225/// describes the call that just returned.
226pub fn last_budget_trip() -> Option<BudgetError> {
227    BUDGET_TRIP.with(|c| c.get())
228}
229
230/// `limit(expr, var, point, dir)` — see [`LimitDirection`].
231///
232/// `point` may be finite or [`ExprPool::pos_infinity`]. Limits at `-∞` use
233/// `pool.mul(pool.integer(-1), pool.pos_infinity())`.
234///
235/// # Termination
236///
237/// The search is bounded: it honours [`crate::budget`] (wall clock, steps,
238/// [`crate::budget::request_cancel`]) and, with no budget active, an internal
239/// work ceiling. Either way an unsolvable case returns
240/// [`LimitError::DepthExceeded`] rather than running unboundedly; use
241/// [`last_budget_trip`] to tell the two apart.
242pub fn limit(
243    expr: ExprId,
244    var: ExprId,
245    point: ExprId,
246    direction: LimitDirection,
247    pool: &ExprPool,
248) -> Result<ExprId, LimitError> {
249    let frame = enter_work_frame(pool);
250    if frame.outermost {
251        BUDGET_TRIP.with(|c| c.set(None));
252    }
253    // Bound the Taylor-coefficient loop in `series` for the whole call, not
254    // just at the boundaries this module can see: a single `local_expansion`
255    // at order 32 is one uninterruptible call from here.
256    let _ceiling = enter_coeff_ceiling(coeff_ceiling(pool));
257
258    limit_body(expr, var, point, direction, pool).map_err(|e| attribute_failure(e, pool))
259}
260
261/// Re-attribute a failed [`limit`] to the resource that actually stopped it.
262///
263/// Every rule in this engine turns a failed sub-problem into "this rule does
264/// not apply" (`Err(_) => Ok(None)`), and the coefficient loop in
265/// [`crate::calculus::series`] simply stops producing terms. So a call that ran
266/// out of budget usually surfaces as `Unsupported` — "no rule worked" — which
267/// is true but useless: it tells the caller to rewrite the problem when what
268/// they need to do is raise the budget. Any failure that coincides with an
269/// exhausted budget, a cancellation, or a blown work ceiling is reported as
270/// [`LimitError::DepthExceeded`], with [`last_budget_trip`] carrying the
271/// `E-BUDGET-*` cause when there was one.
272fn attribute_failure(e: LimitError, pool: &ExprPool) -> LimitError {
273    if BUDGET_TRIP.with(|c| c.get()).is_some() || work_exhausted(pool) {
274        return LimitError::DepthExceeded;
275    }
276    if let Err(b) = crate::budget::check() {
277        BUDGET_TRIP.with(|c| c.set(Some(b)));
278        return LimitError::DepthExceeded;
279    }
280    e
281}
282
283fn limit_body(
284    expr: ExprId,
285    var: ExprId,
286    point: ExprId,
287    direction: LimitDirection,
288    pool: &ExprPool,
289) -> Result<ExprId, LimitError> {
290    let r = limit_inner(expr, var, point, direction, pool, 0)?;
291    let r_simp = simplify(r, pool).value;
292    let r_fold = fold_known_reals(r_simp, pool);
293    let result = simplify(r_fold, pool).value;
294    // A residual `0^{negative}` is not a value: it is what is left over when the
295    // substitution `x ↦ 1/t`, `t → 0` never resolved.  Returning it produces
296    // confident nonsense for limits that do not exist — `lim_{x→∞} sin x` came
297    // back as `sin(0^{-1})` and `lim_{x→0} exp(1/x)` as `exp(0^{-1})`, neither
298    // flagged as an error.  Report the honest failure instead.  Genuine
299    // infinities use the canonical `∞` symbol and are unaffected.
300    if contains_zero_to_negative_power(result, pool) {
301        return Err(LimitError::Unsupported);
302    }
303    if approach_side_is_outside_the_domain(expr, var, point, direction, pool) {
304        return Err(LimitError::Unsupported);
305    }
306    if numeric_evidence_contradicts(expr, var, point, direction, result, pool) {
307        return Err(LimitError::Unsupported);
308    }
309    Ok(result)
310}
311
312/// True when `expr` takes no real value anywhere on the side the caller asked
313/// about, so the one-sided limit does not exist over ℝ.
314///
315/// `lim_{x→0⁻} √x` came back as `0`. It is not that the value is hard to pin
316/// down: `√x` is undefined at *every* point of every left neighbourhood of `0`,
317/// so there is no sequence to take a limit along and the question has no answer
318/// over the reals. Same for `lim_{x→1⁺} arccos x`. A `0` there is the kind of
319/// answer a loop reasoning about domains of definition inherits and cannot
320/// audit — it looks exactly like the (correct) `lim_{x→0⁺} √x = 0`.
321///
322/// The evidence required is positive and cheap:
323///
324/// * every sampled offset on the approach side evaluates to `NaN` — the
325///   interpreter *ran* and the result was not a real number, as opposed to
326///   returning `None` because it did not recognise the expression; and
327/// * the mirror point on the opposite side evaluates to a finite real, which
328///   witnesses that this expression is within the interpreter's vocabulary and
329///   that the `NaN`s are therefore facts about the function's domain.
330///
331/// `±inf` deliberately does **not** count: `lim_{x→0⁻} 1/x = −∞` is a pole, not
332/// a domain boundary, and the answer `−∞` is correct.
333///
334/// Two-sided limits are left alone. There the usual convention takes the limit
335/// relative to the domain, under which `lim_{x→0} √x = 0` is defensible; a
336/// caller who writes `dir="-"` has asked a question that convention does not
337/// cover.
338fn approach_side_is_outside_the_domain(
339    expr: ExprId,
340    var: ExprId,
341    point: ExprId,
342    direction: LimitDirection,
343    pool: &ExprPool,
344) -> bool {
345    let sign = match direction {
346        LimitDirection::Plus => 1.0,
347        LimitDirection::Minus => -1.0,
348        LimitDirection::Bidirectional => return false,
349    };
350    // A polynomial is defined on the whole line; so is anything whose samples
351    // would be meaningless because a second symbol is unbound.
352    if is_polynomial_in(expr, var, pool) || has_free_symbol_besides(expr, var, pool) {
353        return false;
354    }
355    let Some(at) = constant_f64(point, pool) else {
356        return false;
357    };
358
359    let mut env: HashMap<ExprId, f64> = HashMap::with_capacity(1);
360    let mut sample = |offset: f64| -> Option<f64> {
361        env.insert(var, at + offset);
362        crate::jit::eval_interp(expr, &env, pool)
363    };
364
365    for offset in APPROACH_OFFSETS {
366        match sample(sign * offset) {
367            Some(v) if v.is_nan() => {}
368            // Evaluable and real, or not evaluable at all: no verdict.
369            _ => return false,
370        }
371    }
372    // The witness that the expression itself is evaluable.
373    APPROACH_OFFSETS
374        .iter()
375        .any(|&offset| sample(-sign * offset).is_some_and(|v| v.is_finite()))
376}
377
378/// Offsets used to sample a function as it approaches a finite point.
379///
380/// Deliberately stops at `1e-4`: closer in, catastrophic cancellation in
381/// expressions like `(cos x - 1)/x²` dominates the signal and the sampler
382/// would start manufacturing disagreements that are artifacts of binary
383/// floating point rather than facts about the function.
384const APPROACH_OFFSETS: [f64; 4] = [1e-1, 1e-2, 1e-3, 1e-4];
385
386/// A one-sided numeric estimate, kept only when the samples have settled.
387struct SideEstimate {
388    /// Value at the closest offset.
389    value: f64,
390    /// How far the estimate still moved over the last refinement — the scale
391    /// below which a disagreement is not yet meaningful.
392    movement: f64,
393}
394
395/// Sample `expr` approaching `at` from one side, returning an estimate only
396/// when the samples converge.
397///
398/// `sign` is `+1.0` to approach from above, `-1.0` from below. Returns `None`
399/// when the function cannot be evaluated, or when the samples are still moving
400/// enough that no honest verdict can be drawn from them — an oscillating
401/// integrand such as `x·sin(1/x)` must fall in the second bucket, so that this
402/// check stays silent rather than guessing.
403fn side_estimate(
404    expr: ExprId,
405    var: ExprId,
406    at: f64,
407    sign: f64,
408    pool: &ExprPool,
409) -> Option<SideEstimate> {
410    let mut samples = Vec::with_capacity(APPROACH_OFFSETS.len());
411    let mut env: HashMap<ExprId, f64> = HashMap::with_capacity(1);
412    for offset in APPROACH_OFFSETS {
413        env.insert(var, at + sign * offset);
414        match crate::jit::eval_interp(expr, &env, pool) {
415            Some(v) if v.is_finite() => samples.push(v),
416            // A single unevaluable or non-finite sample is not evidence of
417            // anything; it just means this offset landed on a hole.
418            _ => continue,
419        }
420    }
421    if samples.len() < 3 {
422        return None;
423    }
424    let last = samples[samples.len() - 1];
425    let prev = samples[samples.len() - 2];
426    let movement = (last - prev).abs();
427    let scale = 1.0 + last.abs();
428    // Still moving by more than 1% of its own magnitude: not converged.
429    if movement > 0.01 * scale {
430        return None;
431    }
432    Some(SideEstimate {
433        value: last,
434        movement,
435    })
436}
437
438/// True when numeric sampling clearly contradicts the symbolic `result`.
439///
440/// The symbolic machinery can return a confident value that the function never
441/// approaches. `lim_{x→0} x/|x|` came back as `0` in all three directions, when
442/// the one-sided limits are `∓1` and the two-sided limit does not exist —
443/// a plausible finite number with nothing to distinguish it from a correct one.
444/// (The algebraically identical `|x|/x` was refused, so argument order alone
445/// decided whether the caller got a refusal or a wrong answer.)
446///
447/// This is a *refutation* check, not a verification one: it fires only on a
448/// clear contradiction and stays silent whenever the evidence is weak, so it
449/// can turn a wrong answer into a refusal but never a right answer into one.
450/// Every guard below is a reason to say nothing.
451fn numeric_evidence_contradicts(
452    expr: ExprId,
453    var: ExprId,
454    point: ExprId,
455    direction: LimitDirection,
456    result: ExprId,
457    pool: &ExprPool,
458) -> bool {
459    // Checked first, and in this order, because both are whole-expression
460    // walks and polynomials are the common case in hot paths.
461    //
462    // A polynomial is continuous on the whole line: its limit at a finite point
463    // is just its value there, and none of the failure modes this check hunts —
464    // poles, branch cuts, sign discontinuities, one-sided divergence — can
465    // occur. Sampling it can only confirm what substitution already settled.
466    // `is_polynomial_in` also rejects any symbol other than `var`, so passing it
467    // subsumes the free-parameter check below.
468    if is_polynomial_in(expr, var, pool) {
469        return false;
470    }
471    // A free parameter besides `var` makes the samples meaningless.
472    if has_free_symbol_besides(expr, var, pool) {
473        return false;
474    }
475    // Only finite approach points; `∞` is not a place to sample around.
476    let Some(at) = constant_f64(point, pool) else {
477        return false;
478    };
479
480    let claimed = constant_f64(result, pool);
481
482    // Cheap probe before the full analysis. The convergence test below costs up
483    // to eight evaluations, and the overwhelming majority of calls are limits
484    // that are simply correct — one sample per relevant side is enough to see
485    // that and leave. Escalating only on a whiff of disagreement keeps the
486    // common path at two evaluations instead of eight.
487    //
488    // Skipping here can only make the check stay *silent*, never fire wrongly,
489    // which is the direction a refutation check is allowed to be wrong in.
490    if !probe_looks_suspicious(expr, var, at, direction, claimed, pool) {
491        return false;
492    }
493
494    let left = side_estimate(expr, var, at, -1.0, pool);
495    let right = side_estimate(expr, var, at, 1.0, pool);
496
497    // Two-sided: settled but disagreeing sides mean the limit does not exist,
498    // whatever value the symbolic route produced.
499    if direction == LimitDirection::Bidirectional {
500        if let (Some(l), Some(r)) = (&left, &right) {
501            let tol = 1e-6 + 20.0 * (l.movement + r.movement);
502            if (l.value - r.value).abs() > tol {
503                return true;
504            }
505        }
506    }
507
508    // Any direction: compare the symbolic answer against the side(s) it claims
509    // to describe. Only meaningful when the answer is itself a finite number —
510    // `∞` and symbolic results are left alone.
511    let Some(claimed) = claimed else {
512        return false;
513    };
514    let sides: [&Option<SideEstimate>; 2] = match direction {
515        LimitDirection::Plus => [&right, &None],
516        LimitDirection::Minus => [&left, &None],
517        LimitDirection::Bidirectional => [&left, &right],
518    };
519    for side in sides.into_iter().flatten() {
520        let tol = 1e-6 * (1.0 + claimed.abs()) + 20.0 * side.movement;
521        if (side.value - claimed).abs() > tol {
522            return true;
523        }
524    }
525    false
526}
527
528/// True when `expr` is a polynomial in `var` — sums and products of `var`,
529/// constants, and non-negative integer powers thereof.
530///
531/// Conservative: anything it does not recognise (a `Func`, a negative or
532/// non-integer exponent, a symbolic exponent) returns `false`, which merely
533/// costs the caller the sampling it was trying to avoid.
534fn is_polynomial_in(expr: ExprId, var: ExprId, pool: &ExprPool) -> bool {
535    if expr == var {
536        return true;
537    }
538    match pool.get(expr) {
539        ExprData::Integer(_) | ExprData::Rational(_) | ExprData::Float(_) => true,
540        ExprData::Symbol { .. } => false,
541        ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().all(|&x| is_polynomial_in(x, var, pool)),
542        ExprData::Pow { base, exp } => {
543            matches!(pool.get(exp), ExprData::Integer(n) if n.0 >= 0)
544                && is_polynomial_in(base, var, pool)
545        }
546        _ => false,
547    }
548}
549
550/// One sample per relevant side, to decide whether the full convergence
551/// analysis is worth running.
552///
553/// Returns `true` when the closest sample already disagrees with `claimed`
554/// (or, for a two-sided limit with no numeric `claimed`, when the two sides
555/// disagree with each other). A `false` here ends the check, so this is
556/// deliberately biased toward escalating: a needless escalation costs six more
557/// evaluations, while a missed one costs a silent error.
558fn probe_looks_suspicious(
559    expr: ExprId,
560    var: ExprId,
561    at: f64,
562    direction: LimitDirection,
563    claimed: Option<f64>,
564    pool: &ExprPool,
565) -> bool {
566    // Two offsets a hundredfold apart, so the *trend* is visible.
567    //
568    // Comparing a single sample against `claimed` does not work: a function
569    // approaching its limit is legitimately still some distance away at a
570    // finite offset — `(x⁸−1)/(x−1)` is 8.0028 at `x = 1.0001`, not 8 — so an
571    // absolute-tolerance probe escalates for essentially every non-constant
572    // function and saves nothing. What distinguishes a correct limit is that
573    // the samples *close in on* the claimed value as the offset shrinks.
574    let far = APPROACH_OFFSETS[1];
575    let near = APPROACH_OFFSETS[APPROACH_OFFSETS.len() - 1];
576    // One map, rewritten per sample. Allocating a fresh `HashMap` per
577    // evaluation costs more than the evaluation does on small expressions.
578    let mut env: HashMap<ExprId, f64> = HashMap::with_capacity(1);
579    let mut sample = |sign: f64, offset: f64| -> Option<f64> {
580        env.insert(var, at + sign * offset);
581        crate::jit::eval_interp(expr, &env, pool).filter(|v| v.is_finite())
582    };
583
584    let mut side_is_suspicious = |sign: f64| -> bool {
585        let (Some(f_far), Some(f_near)) = (sample(sign, far), sample(sign, near)) else {
586            // Cannot see the trend here. The full analysis needs three samples
587            // on a side, so it would not reach a verdict either — stay silent.
588            return false;
589        };
590        match claimed {
591            // Converging toward the claimed value: nothing to investigate. The
592            // 0.75 factor is deliberately lenient — linear convergence shrinks
593            // the gap a hundredfold over this range, so anything genuinely
594            // heading for `claimed` clears it easily, while `x/|x|` (gap 1 at
595            // both offsets) does not.
596            Some(c) => (f_near - c).abs() > 0.75 * (f_far - c).abs(),
597            None => false,
598        }
599    };
600
601    let left_bad = direction != LimitDirection::Plus && side_is_suspicious(-1.0);
602    let right_bad = direction != LimitDirection::Minus && side_is_suspicious(1.0);
603    if left_bad || right_bad {
604        return true;
605    }
606
607    // No numeric answer to compare against: only the two-sided
608    // does-not-exist check can fire, and it needs both sides.
609    if claimed.is_none() && direction == LimitDirection::Bidirectional {
610        if let (Some(l), Some(r)) = (sample(-1.0, near), sample(1.0, near)) {
611            return (l - r).abs() > 1e-6 * (1.0 + l.abs().max(r.abs()));
612        }
613    }
614    false
615}
616
617/// Evaluate a closed-form expression to `f64`, or `None` if it is not a
618/// constant this interpreter can reduce to a finite number.
619fn constant_f64(expr: ExprId, pool: &ExprPool) -> Option<f64> {
620    let env = HashMap::new();
621    crate::jit::eval_interp(expr, &env, pool).filter(|v| v.is_finite())
622}
623
624/// True when `expr` mentions a symbol other than `var`.
625fn has_free_symbol_besides(expr: ExprId, var: ExprId, pool: &ExprPool) -> bool {
626    if expr == var {
627        return false;
628    }
629    match pool.get(expr) {
630        ExprData::Symbol { .. } => true,
631        ExprData::Add(xs) | ExprData::Mul(xs) => {
632            xs.iter().any(|&x| has_free_symbol_besides(x, var, pool))
633        }
634        ExprData::Pow { base, exp } => {
635            has_free_symbol_besides(base, var, pool) || has_free_symbol_besides(exp, var, pool)
636        }
637        ExprData::Func { args, .. } => args.iter().any(|&a| has_free_symbol_besides(a, var, pool)),
638        _ => false,
639    }
640}
641
642/// True when `expr` contains a `0^n` node with `n` a negative integer — the
643/// unresolved-pole artifact described in [`limit`].
644fn contains_zero_to_negative_power(expr: ExprId, pool: &ExprPool) -> bool {
645    match pool.get(expr) {
646        ExprData::Pow { base, exp } => {
647            let zero_base = matches!(pool.get(base), ExprData::Integer(n) if n.0 == 0);
648            let negative_exp = match pool.get(exp) {
649                ExprData::Integer(n) => n.0 < 0,
650                ExprData::Rational(r) => r.0 < 0,
651                _ => false,
652            };
653            (zero_base && negative_exp)
654                || contains_zero_to_negative_power(base, pool)
655                || contains_zero_to_negative_power(exp, pool)
656        }
657        ExprData::Add(xs) | ExprData::Mul(xs) => {
658            xs.iter().any(|&x| contains_zero_to_negative_power(x, pool))
659        }
660        ExprData::Func { args, .. } => args
661            .iter()
662            .any(|&a| contains_zero_to_negative_power(a, pool)),
663        _ => false,
664    }
665}
666
667/// `(g^m)^n ↦ g^{m n}` when `m,n ∈ ℤ`, so substitutions like `(1/t)^k` become `t^{-k}` Laurent heads.
668fn flatten_nested_integer_pow(expr: ExprId, pool: &ExprPool) -> ExprId {
669    match pool.get(expr) {
670        ExprData::Pow { base, exp } => {
671            let base = flatten_nested_integer_pow(base, pool);
672            let exp_fl = flatten_nested_integer_pow(exp, pool);
673            if let (
674                ExprData::Pow {
675                    base: b2,
676                    exp: inner_exp,
677                },
678                ExprData::Integer(outer_e),
679            ) = (pool.get(base), pool.get(exp_fl))
680            {
681                if let ExprData::Integer(inner_e) = pool.get(inner_exp) {
682                    let prod = inner_e.0.clone() * outer_e.0.clone();
683                    return pool.pow(flatten_nested_integer_pow(b2, pool), pool.integer(prod));
684                }
685            }
686            pool.pow(base, exp_fl)
687        }
688        ExprData::Mul(xs) => pool.mul(
689            xs.iter()
690                .map(|x| flatten_nested_integer_pow(*x, pool))
691                .collect(),
692        ),
693        ExprData::Add(xs) => pool.add(
694            xs.iter()
695                .map(|x| flatten_nested_integer_pow(*x, pool))
696                .collect(),
697        ),
698        ExprData::Func { name, args } => {
699            let na: Vec<ExprId> = args
700                .iter()
701                .map(|a| flatten_nested_integer_pow(*a, pool))
702                .collect();
703            pool.func(name.clone(), na)
704        }
705        _ => expr,
706    }
707}
708
709/// After ``x ↦ 1/t``, common forms are ``Mul(numer, denom^{-1})`` with ``Pow(t,-1)``
710/// sprinkled through both.  Clear those poles by multiplying by ``t^k`` until
711/// numerator and denominator describe an honest polynomial quotient in ``t``.
712fn canonical_polynomial_quotient_in_var(
713    expr: ExprId,
714    t: ExprId,
715    pool: &ExprPool,
716) -> Result<ExprId, LimitError> {
717    let (n_raw, d_raw) = numerator_denominator(expr, pool);
718    let has_trivial_denom = d_raw == pool.integer(1_i32);
719    // When d_raw == 1 the expr might still have negative powers of t in a sum (e.g. 1 + t^{-1}).
720    // Skip k=0 in that case to avoid an infinite loop, but still try higher k values.
721    for k in 0_i64..=40 {
722        if has_trivial_denom && k == 0 {
723            continue;
724        }
725        // Each pass runs `simplify_expanded` twice on the whole expression, so
726        // a 41-iteration sweep over a large input is long enough to need to be
727        // interruptible even though the loop count itself is bounded.
728        checkpoint(pool)?;
729        let tk = pool.pow(t, pool.integer(k));
730        let n = simplify_expanded(pool.mul(vec![tk, n_raw]), pool).value;
731        let d = simplify_expanded(pool.mul(vec![tk, d_raw]), pool).value;
732        let (n, d) = match (poly_normal(n, vec![t], pool), poly_normal(d, vec![t], pool)) {
733            (Ok(nn), Ok(dd)) => (nn, dd),
734            _ => continue,
735        };
736        if let Ok(rf) = RationalFunction::from_symbolic(n, d, vec![t], pool) {
737            let nx = rf.numer.to_expr(pool);
738            let dx = rf.denom.to_expr(pool);
739            return Ok(
740                simplify(pool.mul(vec![nx, pool.pow(dx, pool.integer(-1_i32))]), pool).value,
741            );
742        }
743    }
744    Ok(expr)
745}
746
747fn limit_inner(
748    expr: ExprId,
749    var: ExprId,
750    point: ExprId,
751    direction: LimitDirection,
752    pool: &ExprPool,
753    depth: u32,
754) -> Result<ExprId, LimitError> {
755    const MAX_DEPTH: u32 = 48;
756    const SERIES_ORDER: u32 = 32;
757    if depth > MAX_DEPTH {
758        return Err(LimitError::DepthExceeded);
759    }
760    checkpoint(pool)?;
761
762    if !depends_on(expr, var, pool) {
763        if substitution_is_singular(expr, pool) {
764            return Err(LimitError::Unsupported);
765        }
766        return Ok(fold_known_reals(simplify(expr, pool).value, pool));
767    }
768
769    if let Some(r) = try_special_function_limits(expr, var, point, direction, pool)? {
770        return Ok(r);
771    }
772
773    // Indeterminate power f^g (1^∞, 0^0, ∞^0): rewrite to exp(g·log f).
774    // Runs for finite points as well as ±∞ so textbook forms like
775    // `(1+x)^(1/x) → e` as `x → 0` are not lost to the `1^anything → 1` fold.
776    if let Some(r) = try_indeterminate_power(expr, var, point, direction, pool, depth)? {
777        return Ok(r);
778    }
779
780    // Gruntz algorithm — best for exp/log expressions at +∞ (runs before the 1/t substitution
781    // so the exp structure is still visible in the original variable).
782    if is_pos_infinity(point, pool) {
783        if let Some(r) = try_gruntz(expr, var, pool)? {
784            return Ok(r);
785        }
786    }
787
788    // Leading-order route at ±∞ for algebraic/analytic scales, tried before the
789    // plain `x ↦ 1/t` substitution below: that substitution hands the result to
790    // a Taylor expansion, which cannot see through a radical and instead
791    // differentiates it thirty-two times.
792    if is_pos_infinity(point, pool) || is_neg_infinity(point, pool) {
793        let toward_pos = is_pos_infinity(point, pool);
794        if let Some(r) = try_regularized_infinity_limit(expr, var, toward_pos, pool)? {
795            return Ok(r);
796        }
797    }
798
799    if is_pos_infinity(point, pool) {
800        let t = pool.symbol("__lt_inf", crate::kernel::Domain::Real);
801        let inv_t = pool.pow(t, pool.integer(-1_i32));
802        let mut m = HashMap::new();
803        m.insert(var, inv_t);
804        let after_subs = subs(expr, &m, pool);
805        let after_flatten = flatten_nested_integer_pow(after_subs, pool);
806        let after_canon = canonical_polynomial_quotient_in_var(after_flatten, t, pool)?;
807        let e2 = simplify(after_canon, pool).value;
808        return limit_inner(
809            e2,
810            t,
811            pool.integer(0_i32),
812            LimitDirection::Plus,
813            pool,
814            depth + 1,
815        );
816    }
817
818    if is_neg_infinity(point, pool) {
819        let t = pool.symbol("__lt_ninf", crate::kernel::Domain::Real);
820        let rep = pool.mul(vec![
821            pool.integer(-1_i32),
822            pool.pow(t, pool.integer(-1_i32)),
823        ]);
824        let mut m = HashMap::new();
825        m.insert(var, rep);
826        let canon = canonical_polynomial_quotient_in_var(
827            flatten_nested_integer_pow(subs(expr, &m, pool), pool),
828            t,
829            pool,
830        )?;
831        let e2 = simplify(canon, pool).value;
832        return limit_inner(
833            e2,
834            t,
835            pool.integer(0_i32),
836            LimitDirection::Plus,
837            pool,
838            depth + 1,
839        );
840    }
841
842    if let Some(r) = try_direct_substitution(expr, var, point, pool) {
843        return Ok(r);
844    }
845
846    if let Some(r) = try_x_log_x_at_zero(expr, var, point, direction, pool, depth)? {
847        return Ok(r);
848    }
849
850    if let Some(r) = try_lhopital(expr, var, point, direction, pool, depth)? {
851        return Ok(r);
852    }
853
854    if let Some(r) = try_expansion_limit(expr, var, point, direction, pool, SERIES_ORDER)? {
855        return Ok(r);
856    }
857
858    Err(LimitError::Unsupported)
859}
860
861/// True when `expr` contains an algebraic (non-integer-power) head — `sqrt`,
862/// `cbrt`, or a `Pow` with a fractional exponent.
863fn contains_radical(expr: ExprId, pool: &ExprPool) -> bool {
864    match pool.get(expr) {
865        ExprData::Func { name, args } => {
866            name == "sqrt" || name == "cbrt" || args.iter().any(|&a| contains_radical(a, pool))
867        }
868        ExprData::Pow { base, exp } => {
869            matches!(pool.get(exp), ExprData::Rational(_))
870                || contains_radical(base, pool)
871                || contains_radical(exp, pool)
872        }
873        ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().any(|&x| contains_radical(x, pool)),
874        _ => false,
875    }
876}
877
878/// Leading-order limit of an *algebraic* expression at `±∞`.
879///
880/// Substitutes `x ↦ ±1/t` (`t → 0⁺`), regularizes the result structurally as
881/// `f(±1/t) = t^v · u(t)` with `u` analytic and non-vanishing at `t = 0`
882/// ([`regularize_at_zero`], the same valuation calculus
883/// [`crate::calculus::asymptotic`] expands with), and reads the limit off the
884/// leading Taylor coefficient of `u`.
885///
886/// The generic route below — substitute, clear poles, Taylor-expand the whole
887/// thing — cannot see through a radical: for `√(x²+x) − x` it hands
888/// `√(t⁻² + t⁻¹) − t⁻¹` to a 32-term Taylor expansion, and each successive
889/// derivative of a nested radical is a constant factor larger than the last,
890/// so the call never returns. Pulling the pole out of the radical first
891/// (`√(t⁻²(1+t)) = t⁻¹√(1+t)`) leaves `t⁻¹·(√(1+t) − 1)`, whose analytic part
892/// has bounded derivatives — the expansion is then immediate and exact, and
893/// the ∞−∞ cancellation resolves to `1/2` instead of hanging.
894///
895/// Restricted to expressions that actually contain a radical: everything else
896/// is already served by the existing routes, and this keeps their answers
897/// untouched.
898fn try_regularized_infinity_limit(
899    expr: ExprId,
900    var: ExprId,
901    toward_pos: bool,
902    pool: &ExprPool,
903) -> Result<Option<ExprId>, LimitError> {
904    // Escalated rather than fixed: only the first nonzero coefficient of `u`
905    // decides the limit, and every further coefficient costs one more symbolic
906    // derivative. Stopping at the first order that resolves keeps the common
907    // case at three derivatives instead of thirty-two.
908    const ORDERS: [u32; 3] = [4, 10, 24];
909
910    if !contains_radical(expr, pool) {
911        return Ok(None);
912    }
913
914    // `Domain::Positive`: the substituted variable approaches 0 from above, and
915    // `regularize_at_zero`'s `(t^v·u)^e = t^{v·e}·u^e` step is only valid for
916    // `t > 0`.
917    let t = pool.symbol("__lt_reg", crate::kernel::Domain::Positive);
918    let inv_t = pool.pow(t, pool.integer(-1_i32));
919    let rep = if toward_pos {
920        inv_t
921    } else {
922        pool.mul(vec![pool.integer(-1_i32), inv_t])
923    };
924    let mut m = HashMap::new();
925    m.insert(var, rep);
926    let f_of_t = simplify(subs(expr, &m, pool), pool).value;
927
928    let Some((val, analytic)) = regularize_at_zero(f_of_t, t, pool) else {
929        return Ok(None);
930    };
931    let Ok(val) = i32::try_from(val) else {
932        return Ok(None);
933    };
934
935    let zero = pool.integer(0_i32);
936    for order in ORDERS {
937        checkpoint(pool)?;
938        let Ok(exp) = local_expansion(analytic, t, zero, order, pool) else {
939            return Ok(None);
940        };
941        let LocalExpansion {
942            valuation,
943            coeffs,
944            h_expr,
945        } = exp;
946        let Some(total) = val.checked_add(valuation) else {
947            return Ok(None);
948        };
949        let shifted = LocalExpansion {
950            valuation: total,
951            coeffs,
952            h_expr,
953        };
954        // `t → 0⁺`, so even an odd-order pole has a determinate sign.
955        if let Some(r) = expansion_to_limit(shifted, pool, LimitDirection::Plus)? {
956            return Ok(Some(r));
957        }
958    }
959    Ok(None)
960}
961
962fn try_x_log_x_at_zero(
963    expr: ExprId,
964    var: ExprId,
965    point: ExprId,
966    direction: LimitDirection,
967    pool: &ExprPool,
968    depth: u32,
969) -> Result<Option<ExprId>, LimitError> {
970    if direction == LimitDirection::Minus {
971        return Ok(None);
972    }
973    if !matches!(pool.get(point), ExprData::Integer(n) if n.0 == 0) {
974        return Ok(None);
975    }
976    let ExprData::Mul(args) = pool.get(expr) else {
977        return Ok(None);
978    };
979    if args.len() != 2 {
980        return Ok(None);
981    }
982    let (a, b) = (args[0], args[1]);
983    let log_of_var = |u: ExprId| {
984        matches!(
985            pool.get(u),
986            ExprData::Func { name, args: av } if name == "log" && av.len() == 1 && av[0] == var
987        )
988    };
989    let is_var = |u: ExprId| u == var;
990    let ok = (is_var(a) && log_of_var(b)) || (is_var(b) && log_of_var(a));
991    if !ok {
992        return Ok(None);
993    }
994    // L'Hôpital on log(x) / x^{-1}: (1/x) / (-1/x^2) = -x  → 0 as x→0+.
995    let f = pool.func("log", vec![var]);
996    let g = pool.pow(var, pool.integer(-1_i32));
997    let fp = diff(f, var, pool)?.value;
998    let gp = diff(g, var, pool)?.value;
999    let ratio = rational_quotient(fp, gp, pool);
1000    Ok(Some(limit_inner(
1001        ratio,
1002        var,
1003        point,
1004        LimitDirection::Plus,
1005        pool,
1006        depth + 1,
1007    )?))
1008}
1009
1010/// Detect an indeterminate power `base^exp` as `var → ±∞` and rewrite it to
1011/// `exp(exp · log(base))`, feeding that through the recursive limit machinery
1012/// (Gruntz collects the resulting `exp(h)` and gets the right answer).
1013///
1014/// Only the genuinely indeterminate exponential forms are rewritten:
1015///   * `1^∞`  (base → 1, exp → ±∞)
1016///   * `∞^0`  (base → ±∞, exp → 0)
1017///   * `0^0`  (base → 0, exp → 0)  — only when `base` is structurally positive
1018///
1019/// Non-indeterminate powers (e.g. `2^x → ∞`, `x^2 → ∞`, or `base → c ≠ 1` with
1020/// `exp → ∞`) are left untouched so the existing fast paths still apply and no
1021/// new silent-wrong answers are introduced.  `log(base)` is only formed when we
1022/// can establish `base > 0` near the limit.
1023fn try_indeterminate_power(
1024    expr: ExprId,
1025    var: ExprId,
1026    point: ExprId,
1027    direction: LimitDirection,
1028    pool: &ExprPool,
1029    depth: u32,
1030) -> Result<Option<ExprId>, LimitError> {
1031    let ExprData::Pow { base, exp } = pool.get(expr) else {
1032        return Ok(None);
1033    };
1034    // A constant base (e.g. exp(...) form is already handled, and 2^x is not
1035    // indeterminate) — only proceed when the base genuinely varies with `var`.
1036    if !depends_on(base, var, pool) {
1037        return Ok(None);
1038    }
1039
1040    // Limits of the base and exponent (independently).
1041    let base_lim = match limit_inner(base, var, point, direction, pool, depth + 1) {
1042        Ok(b) => b,
1043        Err(_) => return Ok(None),
1044    };
1045
1046    // When `base → 1`, the classic `1^∞` rewrite `exp(g·log f)` is licensed even
1047    // if `lim g` itself needs a one-sided approach (e.g. `(1+x)^(1/x)` as
1048    // `x → 0`): `lim (g·log f) = lim log(1+x)/x = 1` exists bidirectionally.
1049    if is_one_like(base_lim, pool) {
1050        let log_base = pool.func("log", vec![base]);
1051        let inner = simplify(pool.mul(vec![exp, log_base]), pool).value;
1052        if let Ok(inner_lim) = limit_inner(inner, var, point, direction, pool, depth + 1) {
1053            if is_pos_infinity(inner_lim, pool) {
1054                return Ok(Some(pool.pos_infinity()));
1055            }
1056            if is_neg_infinity(inner_lim, pool) {
1057                return Ok(Some(pool.integer(0_i32)));
1058            }
1059            let result = simplify(pool.func("exp", vec![inner_lim]), pool).value;
1060            return Ok(Some(result));
1061        }
1062    }
1063
1064    let exp_lim = match limit_inner(exp, var, point, direction, pool, depth + 1) {
1065        Ok(e) => e,
1066        Err(_) => return Ok(None),
1067    };
1068
1069    let base_is_one = is_one_like(base_lim, pool);
1070    let base_is_zero = is_zero_like(base_lim, pool);
1071    let base_is_inf = is_pos_infinity(base_lim, pool) || is_neg_infinity(base_lim, pool);
1072    let exp_is_zero = is_zero_like(exp_lim, pool);
1073    let exp_is_inf = is_pos_infinity(exp_lim, pool) || is_neg_infinity(exp_lim, pool);
1074
1075    // Classify the indeterminate exponential forms.
1076    let indeterminate = (base_is_one && exp_is_inf)            // 1^∞
1077        || (base_is_inf && exp_is_zero)                       // ∞^0
1078        || (base_is_zero && exp_is_zero); // 0^0
1079    if !indeterminate {
1080        return Ok(None);
1081    }
1082
1083    // `log(base)` must be valid: require base > 0 near the limit.  base → 1 or
1084    // base → +∞ are positive; base → 0 only qualifies if structurally positive.
1085    let base_positive = base_is_one
1086        || is_pos_infinity(base_lim, pool)
1087        || (base_is_zero && structurally_positive(base, pool));
1088    if !base_positive {
1089        return Ok(None);
1090    }
1091
1092    // Rewrite f^g → exp(g · log f).  Compute the inner limit `L = lim(g · log f)`
1093    // via the existing (correct) machinery, then map it through exp:
1094    //   L finite → exp(L),   L = +∞ → +∞,   L = -∞ → 0.
1095    // Computing L directly (rather than recursing on `exp(g·log f)`) avoids the
1096    // Gruntz `exp(finite)` path, which only retains the leading order and would
1097    // give e.g. exp(1) for (1+2/x)^x instead of exp(2).
1098    let log_base = pool.func("log", vec![base]);
1099    let inner = simplify(pool.mul(vec![exp, log_base]), pool).value;
1100    let inner_lim = match limit_inner(inner, var, point, direction, pool, depth + 1) {
1101        Ok(l) => l,
1102        Err(_) => return Ok(None),
1103    };
1104    if is_pos_infinity(inner_lim, pool) {
1105        return Ok(Some(pool.pos_infinity()));
1106    }
1107    if is_neg_infinity(inner_lim, pool) {
1108        return Ok(Some(pool.integer(0_i32)));
1109    }
1110    // Finite inner limit ⇒ exp(L).
1111    let result = simplify(pool.func("exp", vec![inner_lim]), pool).value;
1112    Ok(Some(result))
1113}
1114
1115/// Conservative structural test that `e > 0` everywhere it is defined — used to
1116/// license `log(e)` for `0^0` rewrites.  `1 + h` with positive constant part,
1117/// positive constants, even powers, and products/sums of positives qualify.
1118fn structurally_positive(e: ExprId, pool: &ExprPool) -> bool {
1119    match pool.get(e) {
1120        ExprData::Integer(n) => n.0 > 0,
1121        ExprData::Rational(r) => r.0 > 0,
1122        ExprData::Func { name, .. } if name == "exp" || name == "cosh" => true,
1123        ExprData::Pow { base, exp } => {
1124            if let ExprData::Integer(n) = pool.get(exp) {
1125                if n.0.clone() % 2 == 0 {
1126                    return true;
1127                }
1128            }
1129            structurally_positive(base, pool)
1130        }
1131        ExprData::Mul(xs) => xs.iter().all(|x| structurally_positive(*x, pool)),
1132        _ => false,
1133    }
1134}
1135
1136fn try_special_function_limits(
1137    expr: ExprId,
1138    var: ExprId,
1139    point: ExprId,
1140    direction: LimitDirection,
1141    pool: &ExprPool,
1142) -> Result<Option<ExprId>, LimitError> {
1143    let ExprData::Func { name, args } = pool.get(expr) else {
1144        return Ok(None);
1145    };
1146    if args.len() != 1 || args[0] != var {
1147        return Ok(None);
1148    }
1149    match name.as_str() {
1150        "exp" => {
1151            if is_pos_infinity(point, pool) {
1152                return Ok(Some(pool.pos_infinity()));
1153            }
1154            if is_neg_infinity(point, pool) {
1155                return Ok(Some(pool.integer(0_i32)));
1156            }
1157            if matches!(pool.get(point), ExprData::Integer(n) if n.0 == 0) {
1158                return Ok(Some(pool.integer(1_i32)));
1159            }
1160        }
1161        "log" => {
1162            if is_pos_infinity(point, pool) {
1163                return Ok(Some(pool.pos_infinity()));
1164            }
1165            if matches!(pool.get(point), ExprData::Integer(n) if n.0 == 0) {
1166                if direction == LimitDirection::Plus {
1167                    return Ok(Some(neg_infinity(pool)));
1168                }
1169                return Err(LimitError::NeedsOneSided);
1170            }
1171        }
1172        _ => {}
1173    }
1174    Ok(None)
1175}
1176
1177fn neg_infinity(pool: &ExprPool) -> ExprId {
1178    pool.mul(vec![pool.integer(-1_i32), pool.pos_infinity()])
1179}
1180
1181fn is_pos_infinity(e: ExprId, pool: &ExprPool) -> bool {
1182    matches!(
1183        pool.get(e),
1184        ExprData::Symbol {
1185            name,
1186            domain: crate::kernel::Domain::Positive,
1187            ..
1188        } if name == POS_INFINITY_SYMBOL
1189    ) || matches!(
1190        pool.get(e),
1191        ExprData::Symbol {
1192            name,
1193            domain: crate::kernel::Domain::Real,
1194            ..
1195        } if name == POS_INFINITY_SYMBOL
1196    )
1197}
1198
1199fn is_neg_infinity(e: ExprId, pool: &ExprPool) -> bool {
1200    let ExprData::Mul(args) = pool.get(e) else {
1201        return false;
1202    };
1203    if args.len() != 2 {
1204        return false;
1205    }
1206    let (a, b) = (args[0], args[1]);
1207    let m_one = pool.integer(-1_i32);
1208    (a == m_one && is_pos_infinity(b, pool)) || (b == m_one && is_pos_infinity(a, pool))
1209}
1210
1211fn depends_on(expr: ExprId, var: ExprId, pool: &ExprPool) -> bool {
1212    if expr == var {
1213        return true;
1214    }
1215    match pool.get(expr) {
1216        ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().any(|a| depends_on(*a, var, pool)),
1217        ExprData::Pow { base, exp } => depends_on(base, var, pool) || depends_on(exp, var, pool),
1218        ExprData::Func { args, .. } => args.iter().any(|a| depends_on(*a, var, pool)),
1219        ExprData::Piecewise { branches, default } => {
1220            branches
1221                .iter()
1222                .any(|(c, v)| depends_on(*c, var, pool) || depends_on(*v, var, pool))
1223                || depends_on(default, var, pool)
1224        }
1225        ExprData::Predicate { args, .. } => args.iter().any(|a| depends_on(*a, var, pool)),
1226        ExprData::Forall { var: bv, body } | ExprData::Exists { var: bv, body } => {
1227            bv != var && depends_on(body, var, pool)
1228        }
1229        ExprData::RootSum {
1230            poly,
1231            var: bv,
1232            body,
1233        } => depends_on(poly, var, pool) || (bv != var && depends_on(body, var, pool)),
1234        ExprData::BigO(a) => depends_on(a, var, pool),
1235        ExprData::Integer(_)
1236        | ExprData::Rational(_)
1237        | ExprData::Float(_)
1238        | ExprData::Symbol { .. } => false,
1239    }
1240}
1241
1242fn try_direct_substitution(
1243    expr: ExprId,
1244    var: ExprId,
1245    point: ExprId,
1246    pool: &ExprPool,
1247) -> Option<ExprId> {
1248    if quotient_is_zero_over_zero(expr, var, point, pool) {
1249        return None;
1250    }
1251    let mut m = HashMap::new();
1252    m.insert(var, point);
1253    let raw = subs(expr, &m, pool);
1254    if is_zero_times_pole_indeterminate(raw, pool) {
1255        return None;
1256    }
1257    let sub = fold_known_reals(simplify(raw, pool).value, pool);
1258    let dep = depends_on(sub, var, pool);
1259    let sing = substitution_is_singular(sub, pool);
1260    if dep || sing {
1261        None
1262    } else {
1263        Some(sub)
1264    }
1265}
1266
1267/// True when ``expr`` is a product quotient `n/d` with `n,d → 0` at substitution (classic `0/0`).
1268fn quotient_is_zero_over_zero(expr: ExprId, var: ExprId, point: ExprId, pool: &ExprPool) -> bool {
1269    let (n, d) = numerator_denominator(expr, pool);
1270    if d == pool.integer(1_i32) {
1271        return false;
1272    }
1273    let n0 = substitute_fully(n, var, point, pool);
1274    let d0 = substitute_fully(d, var, point, pool);
1275    is_zero_like(n0, pool) && is_zero_like(d0, pool)
1276}
1277
1278/// `0 · (pole at 0)` style indeterminate — must not simplify to misleading `0`.
1279fn is_zero_times_pole_indeterminate(expr: ExprId, pool: &ExprPool) -> bool {
1280    let factors: Vec<ExprId> = if matches!(pool.get(expr), ExprData::Mul(_)) {
1281        flatten_mul(expr, pool)
1282    } else {
1283        vec![expr]
1284    };
1285    let mut any_zero_factor = false;
1286    let mut any_pole = false;
1287    for f in factors {
1288        if substitution_is_singular(f, pool) {
1289            any_pole = true;
1290        }
1291        if matches!(pool.get(f), ExprData::Integer(z) if z.0 == 0) {
1292            any_zero_factor = true;
1293        }
1294        if let ExprData::Func { name, args } = pool.get(f) {
1295            if args.len() == 1
1296                && matches!(name.as_str(), "sin" | "sinh" | "tan")
1297                && matches!(pool.get(args[0]), ExprData::Integer(z) if z.0 == 0)
1298            {
1299                any_zero_factor = true;
1300            }
1301        }
1302    }
1303    any_zero_factor && any_pole
1304}
1305
1306/// `true` after substitution if some sub-expression is ``0^{-n}`` (possibly nested via ``(0^{-1})^e``).
1307fn substitution_is_singular(expr: ExprId, pool: &ExprPool) -> bool {
1308    match pool.get(expr) {
1309        ExprData::Pow { base, exp } => {
1310            if let ExprData::Integer(nn) = pool.get(exp) {
1311                if nn.0 < 0 {
1312                    let b = simplify(base, pool).value;
1313                    if matches!(pool.get(b), ExprData::Integer(z) if z.0 == 0) {
1314                        return true;
1315                    }
1316                }
1317            }
1318            substitution_is_singular(base, pool) || substitution_is_singular(exp, pool)
1319        }
1320        ExprData::Add(xs) | ExprData::Mul(xs) => {
1321            xs.iter().any(|a| substitution_is_singular(*a, pool))
1322        }
1323        ExprData::Func { args, .. } => args.iter().any(|a| substitution_is_singular(*a, pool)),
1324        _ => false,
1325    }
1326}
1327
1328fn try_lhopital(
1329    expr: ExprId,
1330    var: ExprId,
1331    point: ExprId,
1332    direction: LimitDirection,
1333    pool: &ExprPool,
1334    depth: u32,
1335) -> Result<Option<ExprId>, LimitError> {
1336    let (nume, deno) = numerator_denominator(expr, pool);
1337    if simplify(nume, pool).value == simplify(deno, pool).value {
1338        return Ok(None);
1339    }
1340    let n0 = substitute_fully(nume, var, point, pool);
1341    let d0 = substitute_fully(deno, var, point, pool);
1342
1343    if !is_zero_like(n0, pool) || !is_zero_like(d0, pool) {
1344        return Ok(None);
1345    }
1346
1347    let dn = diff(nume, var, pool)?.value;
1348    let dd = diff(deno, var, pool)?.value;
1349    if dn == nume && dd == deno {
1350        return Ok(None);
1351    }
1352    let quot = rational_quotient(dn, dd, pool);
1353    Ok(Some(limit_inner(
1354        quot,
1355        var,
1356        point,
1357        direction,
1358        pool,
1359        depth + 1,
1360    )?))
1361}
1362
1363fn substitute_fully(expr: ExprId, var: ExprId, point: ExprId, pool: &ExprPool) -> ExprId {
1364    let mut m = HashMap::new();
1365    m.insert(var, point);
1366    let s = simplify(subs(expr, &m, pool), pool).value;
1367    fold_known_reals(s, pool)
1368}
1369
1370fn rational_quotient(n: ExprId, d: ExprId, pool: &ExprPool) -> ExprId {
1371    simplify(pool.mul(vec![n, pool.pow(d, pool.integer(-1_i32))]), pool).value
1372}
1373
1374fn is_zero_like(e: ExprId, pool: &ExprPool) -> bool {
1375    let e = simplify(e, pool).value;
1376    if matches!(pool.get(e), ExprData::Integer(n) if n.0 == 0) {
1377        return true;
1378    }
1379    if let ExprData::Rational(r) = pool.get(e) {
1380        if r.0 == 0 {
1381            return true;
1382        }
1383    }
1384    if let ExprData::Func { name, args } = pool.get(e) {
1385        if args.len() == 1 && matches!(name.as_str(), "sin" | "tan" | "sinh") {
1386            return is_zero_like(args[0], pool);
1387        }
1388    }
1389    false
1390}
1391
1392fn is_one_like(e: ExprId, pool: &ExprPool) -> bool {
1393    let e = simplify(e, pool).value;
1394    if matches!(pool.get(e), ExprData::Integer(n) if n.0 == 1) {
1395        return true;
1396    }
1397    if let ExprData::Rational(r) = pool.get(e) {
1398        return r.0 == 1;
1399    }
1400    false
1401}
1402
1403/// Constant-fold `sin`, `cos`, `exp`, … after limits (`sin(0) → 0`, `cos(0) → 1`).
1404fn fold_known_reals(expr: ExprId, pool: &ExprPool) -> ExprId {
1405    let e = simplify(expr, pool).value;
1406    match pool.get(e) {
1407        ExprData::Add(xs) => {
1408            let ys: Vec<ExprId> = xs.iter().map(|x| fold_known_reals(*x, pool)).collect();
1409            simplify(pool.add(ys), pool).value
1410        }
1411        ExprData::Mul(xs) => {
1412            let ys: Vec<ExprId> = xs.iter().map(|x| fold_known_reals(*x, pool)).collect();
1413            simplify(pool.mul(ys), pool).value
1414        }
1415        ExprData::Pow { base, exp } => {
1416            let b = fold_known_reals(base, pool);
1417            let xp = fold_known_reals(exp, pool);
1418            // `1^∞` / `1^(singular)` must not collapse to 1 — that silently
1419            // turns `(1+x)^(1/x)|_{x=0}` into 1 before the indeterminate-power
1420            // rewrite can recover `e`.
1421            if is_one_like(b, pool) {
1422                if substitution_is_singular(xp, pool)
1423                    || is_pos_infinity(xp, pool)
1424                    || is_neg_infinity(xp, pool)
1425                {
1426                    return simplify(pool.pow(b, xp), pool).value;
1427                }
1428                return pool.integer(1_i32);
1429            }
1430            simplify(pool.pow(b, xp), pool).value
1431        }
1432        ExprData::Func { name, args } if args.len() == 1 => {
1433            let inner = fold_known_reals(args[0], pool);
1434            if is_zero_like(inner, pool) {
1435                match name.as_str() {
1436                    "sin" | "tan" | "sinh" => return pool.integer(0_i32),
1437                    "cos" | "cosh" => return pool.integer(1_i32),
1438                    "exp" => return pool.integer(1_i32),
1439                    _ => {}
1440                }
1441            }
1442            simplify(pool.func(name, vec![inner]), pool).value
1443        }
1444        ExprData::Func { name, args } => {
1445            let ys: Vec<ExprId> = args.iter().map(|x| fold_known_reals(*x, pool)).collect();
1446            simplify(pool.func(name, ys), pool).value
1447        }
1448        _ => e,
1449    }
1450}
1451
1452fn flatten_mul(expr: ExprId, pool: &ExprPool) -> Vec<ExprId> {
1453    match pool.get(expr) {
1454        ExprData::Mul(xs) => xs.iter().flat_map(|a| flatten_mul(*a, pool)).collect(),
1455        _ => vec![expr],
1456    }
1457}
1458
1459fn numerator_denominator(expr: ExprId, pool: &ExprPool) -> (ExprId, ExprId) {
1460    let fac = flatten_mul(expr, pool);
1461    let mut nums = Vec::new();
1462    let mut dens = Vec::new();
1463    for f in fac {
1464        match pool.get(f) {
1465            ExprData::Pow { base, exp } => {
1466                if let ExprData::Integer(n) = pool.get(exp) {
1467                    let nn = &n.0;
1468                    if *nn == 0 {
1469                        nums.push(pool.integer(1_i32));
1470                    } else if *nn > 0 {
1471                        nums.push(f);
1472                    } else {
1473                        let m = nn
1474                            .clone()
1475                            .abs()
1476                            .to_u64()
1477                            .and_then(|u| u32::try_from(u).ok())
1478                            .map(|mag| pool.pow(base, pool.integer(mag as i64)));
1479                        if let Some(p) = m {
1480                            dens.push(p);
1481                        } else {
1482                            nums.push(f);
1483                        }
1484                    }
1485                } else {
1486                    nums.push(f);
1487                }
1488            }
1489            _ => nums.push(f),
1490        }
1491    }
1492    let n = if nums.is_empty() {
1493        pool.integer(1_i32)
1494    } else if nums.len() == 1 {
1495        nums[0]
1496    } else {
1497        pool.mul(nums)
1498    };
1499    let d = if dens.is_empty() {
1500        pool.integer(1_i32)
1501    } else if dens.len() == 1 {
1502        dens[0]
1503    } else {
1504        pool.mul(dens)
1505    };
1506    (n, d)
1507}
1508
1509fn try_expansion_limit(
1510    expr: ExprId,
1511    var: ExprId,
1512    point: ExprId,
1513    direction: LimitDirection,
1514    pool: &ExprPool,
1515    order: u32,
1516) -> Result<Option<ExprId>, LimitError> {
1517    let exp = match local_expansion(expr, var, point, order, pool) {
1518        Ok(e) => e,
1519        Err(_) => {
1520            checkpoint(pool)?;
1521            return Ok(None);
1522        }
1523    };
1524    let r = expansion_to_limit(exp, pool, direction)?;
1525    if r.is_none() {
1526        // The expansion resolved nothing. If that is because the coefficient
1527        // loop hit the work ceiling (or a budget) part-way, say so rather than
1528        // letting the caller report `Unsupported`.
1529        checkpoint(pool)?;
1530    }
1531    Ok(r)
1532}
1533
1534fn expansion_to_limit(
1535    exp: LocalExpansion,
1536    pool: &ExprPool,
1537    direction: LimitDirection,
1538) -> Result<Option<ExprId>, LimitError> {
1539    let LocalExpansion {
1540        valuation,
1541        coeffs,
1542        h_expr: _,
1543    } = exp;
1544
1545    let mut idx = 0usize;
1546    while idx < coeffs.len() && is_zero_like(coeffs[idx], pool) {
1547        idx += 1;
1548    }
1549    if idx >= coeffs.len() {
1550        // Truncation hit all zeros — indeterminate within this order.
1551        return Ok(None);
1552    }
1553    let power = valuation + idx as i32;
1554    let coeff = coeffs[idx];
1555
1556    if power > 0 {
1557        return Ok(Some(pool.integer(0_i32)));
1558    }
1559    if power == 0 {
1560        return Ok(Some(coeff));
1561    }
1562
1563    // Polar — power < 0
1564    let pole_order = (-power) as u32;
1565    let sgn_c = structural_sign(coeff, pool).unwrap_or(1);
1566    if pole_order % 2 == 0 {
1567        return Ok(Some(signed_infinity(pool, sgn_c)));
1568    }
1569    let Some(hdir) = sign_from_h(direction, power) else {
1570        return Err(LimitError::NeedsOneSided);
1571    };
1572    Ok(Some(signed_infinity(pool, sgn_c * hdir)))
1573}
1574
1575/// For odd pole: sign of `h^power` with `power < 0` as `h → 0` from one side.
1576fn sign_from_h(direction: LimitDirection, power: i32) -> Option<i8> {
1577    if power >= 0 {
1578        return Some(1);
1579    }
1580    let odd = (-power) % 2 != 0;
1581    if !odd {
1582        return Some(1);
1583    }
1584    match direction {
1585        LimitDirection::Plus => Some(1),
1586        LimitDirection::Minus => Some(-1),
1587        LimitDirection::Bidirectional => None,
1588    }
1589}
1590
1591fn signed_infinity(pool: &ExprPool, sign: i8) -> ExprId {
1592    if sign < 0 {
1593        neg_infinity(pool)
1594    } else {
1595        pool.pos_infinity()
1596    }
1597}
1598
1599fn structural_sign(e: ExprId, pool: &ExprPool) -> Option<i8> {
1600    match pool.get(e) {
1601        ExprData::Integer(n) => {
1602            if n.0 > 0 {
1603                Some(1)
1604            } else if n.0 < 0 {
1605                Some(-1)
1606            } else {
1607                None
1608            }
1609        }
1610        ExprData::Rational(r) => {
1611            if r.0 == 0 {
1612                None
1613            } else if r.0 > 0 {
1614                Some(1)
1615            } else {
1616                Some(-1)
1617            }
1618        }
1619        ExprData::Mul(xs) => {
1620            let mut s = 1i8;
1621            for a in xs {
1622                let sa = structural_sign(a, pool)?;
1623                s *= sa;
1624            }
1625            Some(s)
1626        }
1627        ExprData::Pow { base: _, exp } if matches!(pool.get(exp), ExprData::Integer(n) if n.0.clone() % 2 == 0) => {
1628            Some(1)
1629        }
1630        _ => None,
1631    }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637    use crate::kernel::Domain;
1638
1639    #[test]
1640    fn limit_sin_over_x_zero() {
1641        let p = ExprPool::new();
1642        let x = p.symbol("x", Domain::Real);
1643        let ex = simplify(
1644            p.mul(vec![p.func("sin", vec![x]), p.pow(x, p.integer(-1_i32))]),
1645            &p,
1646        )
1647        .value;
1648        let r = limit(ex, x, p.integer(0_i32), LimitDirection::Bidirectional, &p).unwrap();
1649        assert_eq!(r, p.integer(1_i32));
1650    }
1651
1652    #[test]
1653    fn one_sided_limit_off_the_domain_is_refused() {
1654        // √x is undefined at every point of every left neighbourhood of 0, so
1655        // there is no sequence along which to take `lim_{x→0⁻} √x` and the
1656        // question has no answer over ℝ. It used to return `√0 = 0` —
1657        // indistinguishable from the correct `lim_{x→0⁺} √x = 0`.
1658        let p = ExprPool::new();
1659        let x = p.symbol("x", Domain::Real);
1660        let ex = simplify(p.func("sqrt", vec![x]), &p).value;
1661        assert!(
1662            limit(ex, x, p.integer(0_i32), LimitDirection::Minus, &p).is_err(),
1663            "√x has no left-hand limit at 0 over ℝ"
1664        );
1665        // The control: from the right the limit exists and is 0.
1666        let r = limit(ex, x, p.integer(0_i32), LimitDirection::Plus, &p).unwrap();
1667        assert_eq!(constant_f64(r, &p), Some(0.0), "got {}", p.display(r));
1668
1669        // arccos is undefined to the right of 1, for the same reason.
1670        let ac = simplify(p.func("acos", vec![x]), &p).value;
1671        assert!(
1672            limit(ac, x, p.integer(1_i32), LimitDirection::Plus, &p).is_err(),
1673            "arccos has no right-hand limit at 1 over ℝ"
1674        );
1675
1676        // …and a pole is *not* a domain boundary: 1/x is perfectly well
1677        // defined to the left of 0 and the one-sided limit is −∞.
1678        let inv = simplify(p.pow(x, p.integer(-1_i32)), &p).value;
1679        assert!(
1680            limit(inv, x, p.integer(0_i32), LimitDirection::Minus, &p).is_ok(),
1681            "lim_{{x→0⁻}} 1/x = −∞ must survive"
1682        );
1683        // √(x²) is defined on both sides; nothing to refuse.
1684        let sq = simplify(p.func("sqrt", vec![p.pow(x, p.integer(2_i32))]), &p).value;
1685        let r = limit(sq, x, p.integer(0_i32), LimitDirection::Minus, &p).unwrap();
1686        assert_eq!(constant_f64(r, &p), Some(0.0), "got {}", p.display(r));
1687    }
1688
1689    #[test]
1690    fn limit_x_log_x_zero_plus() {
1691        let p = ExprPool::new();
1692        let x = p.symbol("x", Domain::Real);
1693        let ex = simplify(p.mul(vec![x, p.func("log", vec![x])]), &p).value;
1694        let r = limit(ex, x, p.integer(0_i32), LimitDirection::Plus, &p).unwrap();
1695        assert_eq!(r, p.integer(0_i32));
1696    }
1697
1698    #[test]
1699    fn limit_exp_inf() {
1700        let p = ExprPool::new();
1701        let x = p.symbol("x", Domain::Real);
1702        let ex = p.func("exp", vec![x]);
1703        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1704        assert_eq!(r, p.pos_infinity());
1705    }
1706
1707    #[test]
1708    fn limit_x_squared_at_positive_infinity() {
1709        let p = ExprPool::new();
1710        let x = p.symbol("x", Domain::Real);
1711        let ex = simplify(p.pow(x, p.integer(2_i32)), &p).value;
1712        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1713        assert_eq!(r, p.pos_infinity(), "{}", p.display(r));
1714    }
1715
1716    /// `lim_{x→∞} (1 + 1/x)^x = e = exp(1)`  (the silent-wrong-answer regression).
1717    #[test]
1718    fn limit_compound_interest_is_e() {
1719        let p = ExprPool::new();
1720        let x = p.symbol("x", Domain::Real);
1721        // (1 + 1/x)^x
1722        let base = p.add(vec![p.integer(1), p.pow(x, p.integer(-1))]);
1723        let ex = simplify(p.pow(base, x), &p).value;
1724        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1725        let expected = simplify(p.func("exp", vec![p.integer(1)]), &p).value;
1726        assert_eq!(r, expected, "got {}", p.display(r));
1727    }
1728
1729    /// `lim_{x→∞} (1 + a/x)^x = exp(a)` for a concrete integer `a = 2`.
1730    #[test]
1731    fn limit_one_plus_a_over_x_pow_x_is_exp_a() {
1732        let p = ExprPool::new();
1733        let x = p.symbol("x", Domain::Real);
1734        // (1 + 2/x)^x
1735        let two_over_x = p.mul(vec![p.integer(2), p.pow(x, p.integer(-1))]);
1736        let base = p.add(vec![p.integer(1), two_over_x]);
1737        let ex = simplify(p.pow(base, x), &p).value;
1738        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1739        let expected = simplify(p.func("exp", vec![p.integer(2)]), &p).value;
1740        assert_eq!(r, expected, "got {}", p.display(r));
1741    }
1742
1743    /// Non-regression: `2^x` has a constant base (not the indeterminate `1^∞`
1744    /// form), so the new rewrite must NOT fire and must NOT fabricate a finite
1745    /// value.  The engine declines it (as it did before this fix); the key point
1746    /// is that it never returns a wrong finite limit.
1747    #[test]
1748    fn limit_two_pow_x_not_rewritten_to_finite() {
1749        let p = ExprPool::new();
1750        let x = p.symbol("x", Domain::Real);
1751        let ex = simplify(p.pow(p.integer(2), x), &p).value;
1752        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p);
1753        // Either it stays unsupported or returns +∞ — but never a finite number.
1754        if let Ok(v) = r {
1755            assert_eq!(
1756                v,
1757                p.pos_infinity(),
1758                "2^x must not be a finite value: {}",
1759                p.display(v)
1760            );
1761        }
1762    }
1763
1764    /// `lim_{x→0} (1 + x)^(1/x) = e` — the finite-point twin of the compound-interest form.
1765    #[test]
1766    fn limit_one_plus_x_to_one_over_x_is_e() {
1767        let p = ExprPool::new();
1768        let x = p.symbol("x", Domain::Real);
1769        let base = p.add(vec![p.integer(1), x]);
1770        let ex = simplify(p.pow(base, p.pow(x, p.integer(-1))), &p).value;
1771        let r = limit(ex, x, p.integer(0_i32), LimitDirection::Bidirectional, &p).unwrap();
1772        let expected = simplify(p.func("exp", vec![p.integer(1)]), &p).value;
1773        assert_eq!(r, expected, "got {}", p.display(r));
1774    }
1775
1776    /// Non-regression: `lim_{x→∞} (1 + 1/x) = 1` (not a power; sanity that the
1777    /// helper does not perturb the simple base limit).
1778    #[test]
1779    fn limit_one_plus_one_over_x_is_one() {
1780        let p = ExprPool::new();
1781        let x = p.symbol("x", Domain::Real);
1782        let ex = simplify(p.add(vec![p.integer(1), p.pow(x, p.integer(-1))]), &p).value;
1783        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1784        assert_eq!(r, p.integer(1), "got {}", p.display(r));
1785    }
1786
1787    #[test]
1788    fn rational_x_over_x_plus_one_after_inf_subst() {
1789        let p = ExprPool::new();
1790        let t = p.symbol("__lt_inf", Domain::Real);
1791        let inv = p.pow(t, p.integer(-1));
1792        let ex = p.mul(vec![
1793            inv,
1794            p.pow(p.add(vec![p.integer(1), inv]), p.integer(-1)),
1795        ]);
1796        let folded = flatten_nested_integer_pow(ex, &p);
1797        let canon = canonical_polynomial_quotient_in_var(folded, t, &p).unwrap();
1798        let r = simplify(canon, &p).value;
1799        let mut m = HashMap::new();
1800        m.insert(t, p.integer(0));
1801        let sub = fold_known_reals(simplify(subs(r, &m, &p), &p).value, &p);
1802        assert_eq!(sub, p.integer(1), "canonical={}", p.display(canon));
1803    }
1804}
1805
1806/// Termination: the engine must always come back, with a value or a coded
1807/// refusal, and must stop when the ambient budget says so.
1808#[cfg(test)]
1809mod termination_tests {
1810    use super::*;
1811    use crate::budget::{self, Budget, BudgetError};
1812    use crate::errors::AlkahestError;
1813    use crate::kernel::Domain;
1814
1815    /// `√(x²+x) − x` at `+∞`: an `∞−∞` cancellation whose conjugate is
1816    /// `x/(√(x²+x)+x) → 1/2`.
1817    ///
1818    /// This call did not return at all — the `x ↦ 1/t` substitution handed
1819    /// `√(t⁻²+t⁻¹)` to a 32-term Taylor expansion, and each derivative of a
1820    /// nested radical is a constant factor larger than the last.
1821    #[test]
1822    fn sqrt_x_squared_plus_x_minus_x_at_infinity_is_one_half() {
1823        let p = ExprPool::new();
1824        let x = p.symbol("x", Domain::Real);
1825        let root = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1826        let ex = simplify(p.add(vec![root, p.mul(vec![p.integer(-1), x])]), &p).value;
1827        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1828        assert_eq!(r, p.rational(1, 2), "got {}", p.display(r));
1829    }
1830
1831    /// The same cancellation with other coefficients, and its mirror at `−∞`.
1832    #[test]
1833    fn algebraic_cancellations_at_infinity() {
1834        let p = ExprPool::new();
1835        let x = p.symbol("x", Domain::Real);
1836        let neg_inf = p.mul(vec![p.integer(-1), p.pos_infinity()]);
1837
1838        // √(x²+3x) − x → 3/2
1839        let root = p.func(
1840            "sqrt",
1841            vec![p.add(vec![p.pow(x, p.integer(2)), p.mul(vec![p.integer(3), x])])],
1842        );
1843        let ex = simplify(p.add(vec![root, p.mul(vec![p.integer(-1), x])]), &p).value;
1844        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1845        assert_eq!(r, p.rational(3, 2), "√(x²+3x)−x: {}", p.display(r));
1846
1847        // √(x²+1) − x → 0
1848        let root = p.func(
1849            "sqrt",
1850            vec![p.add(vec![p.pow(x, p.integer(2)), p.integer(1)])],
1851        );
1852        let ex = simplify(p.add(vec![root, p.mul(vec![p.integer(-1), x])]), &p).value;
1853        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1854        assert_eq!(r, p.integer(0), "√(x²+1)−x: {}", p.display(r));
1855
1856        // As x → −∞ there is no cancellation: √(x²+x) ~ |x| = −x, so the sum
1857        // is ~ −2x → +∞.
1858        let root = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1859        let ex = simplify(p.add(vec![root, p.mul(vec![p.integer(-1), x])]), &p).value;
1860        let r = limit(ex, x, neg_inf, LimitDirection::Bidirectional, &p).unwrap();
1861        assert_eq!(r, p.pos_infinity(), "√(x²+x)−x at −∞: {}", p.display(r));
1862    }
1863
1864    /// A radical limit the engine cannot solve must still come back — with
1865    /// `E-LIMIT-004`, not by running forever — when no budget is active.
1866    #[test]
1867    fn unsolvable_radical_limit_refuses_within_the_work_ceiling() {
1868        let p = ExprPool::new();
1869        let x = p.symbol("x", Domain::Real);
1870        // √(√(x²+x) + x): a half-integer scale the regularizer declines, so
1871        // this falls through to the expansion route that used to run away.
1872        let inner = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1873        let ex = p.func("sqrt", vec![p.add(vec![inner, x])]);
1874        // No wall-clock assertion here on purpose. Termination *is* the property
1875        // under test, and this call returning at all already proves it: before
1876        // the work ceiling existed this expression ran effectively forever, so a
1877        // regression hangs the test rather than failing a timing bound. An
1878        // elapsed() budget would only add flakiness — the AddressSanitizer job
1879        // builds with -Z build-std and runs many times slower than a normal
1880        // build, and a 30s bound that held locally failed there while the
1881        // refusal itself worked correctly.
1882        let err = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap_err();
1883        assert!(
1884            matches!(err, LimitError::DepthExceeded),
1885            "expected a bounded refusal, got {err:?}"
1886        );
1887        assert_eq!(err.code(), "E-LIMIT-004");
1888        // Not a budget trip — the internal ceiling stopped it.
1889        assert_eq!(last_budget_trip(), None);
1890    }
1891
1892    /// A step budget stops the search and is reported as a budget trip, so a
1893    /// binding can raise `E-BUDGET-002` rather than "this limit is too hard".
1894    ///
1895    /// Steps and wall clock live on the thread-local budget stack, so this is
1896    /// safe to run in parallel with the rest of the suite (unlike the
1897    /// process-wide cancellation flag, which `budget`'s own tests serialize).
1898    #[test]
1899    fn step_budget_stops_a_hard_limit_and_is_attributed() {
1900        let p = ExprPool::new();
1901        let x = p.symbol("x", Domain::Real);
1902        let inner = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1903        let ex = p.func("sqrt", vec![p.add(vec![inner, x])]);
1904
1905        let _guard = budget::enter(Budget::new().with_max_steps(3));
1906        let err = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap_err();
1907        assert!(matches!(err, LimitError::DepthExceeded), "{err:?}");
1908        assert!(
1909            matches!(last_budget_trip(), Some(BudgetError::Steps { .. })),
1910            "budget trip not recorded: {:?}",
1911            last_budget_trip()
1912        );
1913    }
1914
1915    /// A limit that *succeeds* under a generous budget must not be reported as
1916    /// a budget trip, and must not leave a stale trip behind for the next call.
1917    #[test]
1918    fn a_solved_limit_leaves_no_budget_trip() {
1919        let p = ExprPool::new();
1920        let x = p.symbol("x", Domain::Real);
1921        {
1922            let _guard = budget::enter(Budget::new().with_max_steps(3));
1923            let inner = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1924            let ex = p.func("sqrt", vec![p.add(vec![inner, x])]);
1925            assert!(limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).is_err());
1926            assert!(last_budget_trip().is_some());
1927        }
1928        let root = p.func("sqrt", vec![p.add(vec![p.pow(x, p.integer(2)), x])]);
1929        let ex = simplify(p.add(vec![root, p.mul(vec![p.integer(-1), x])]), &p).value;
1930        let r = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p).unwrap();
1931        assert_eq!(r, p.rational(1, 2));
1932        assert_eq!(last_budget_trip(), None, "stale trip left behind");
1933    }
1934
1935    /// The work ceiling bounds a whole `limit` call, not each re-entry: Gruntz
1936    /// sub-limits call back into `limit`, and a per-call baseline would reset
1937    /// the ceiling every time and never trip.
1938    #[test]
1939    fn work_baseline_is_installed_once_and_cleared_on_exit() {
1940        let p = ExprPool::new();
1941        let x = p.symbol("x", Domain::Real);
1942        assert!(WORK_BASELINE.with(|c| c.get()).is_none());
1943        let ex = simplify(p.pow(x, p.integer(2)), &p).value;
1944        let _ = limit(ex, x, p.pos_infinity(), LimitDirection::Bidirectional, &p);
1945        assert!(
1946            WORK_BASELINE.with(|c| c.get()).is_none(),
1947            "baseline leaked past the outermost call"
1948        );
1949    }
1950}
1951
1952#[cfg(test)]
1953mod numeric_refutation_tests {
1954    use super::*;
1955    use crate::kernel::Domain;
1956
1957    /// `x/|x|` is `sign(x)`: it never takes the value 0, yet the symbolic
1958    /// route returned 0 in all three directions.
1959    ///
1960    /// The algebraically identical `|x|/x` was already refused, so before this
1961    /// guard the *order of the operands* decided whether a caller got an honest
1962    /// refusal or a confident wrong answer.
1963    #[test]
1964    fn sign_function_limit_is_refused_in_every_direction() {
1965        for direction in [
1966            LimitDirection::Bidirectional,
1967            LimitDirection::Plus,
1968            LimitDirection::Minus,
1969        ] {
1970            let p = ExprPool::new();
1971            let x = p.symbol("x", Domain::Real);
1972            let ex = p.mul(vec![x, p.pow(p.func("abs", vec![x]), p.integer(-1_i32))]);
1973            let got = limit(ex, x, p.integer(0_i32), direction, &p);
1974            assert!(
1975                got.is_err(),
1976                "x/|x| at 0 ({direction:?}) should refuse, got {}",
1977                p.display(got.unwrap())
1978            );
1979        }
1980    }
1981
1982    /// The guard must not fire on limits that genuinely exist, including ones
1983    /// that need cancellation to evaluate.
1984    #[test]
1985    fn ordinary_limits_survive_the_guard() {
1986        let p = ExprPool::new();
1987        let x = p.symbol("x", Domain::Real);
1988        let one = p.integer(1_i32);
1989
1990        let sinc = simplify(
1991            p.mul(vec![p.func("sin", vec![x]), p.pow(x, p.integer(-1_i32))]),
1992            &p,
1993        )
1994        .value;
1995        assert_eq!(
1996            limit(sinc, x, p.integer(0_i32), LimitDirection::Bidirectional, &p).unwrap(),
1997            one
1998        );
1999
2000        // (1 - cos x)/x² = 1/2 — the case that would break if the sampler
2001        // pushed closer than 1e-4 and hit catastrophic cancellation.
2002        let half = p.mul(vec![
2003            p.add(vec![
2004                one,
2005                p.mul(vec![p.integer(-1_i32), p.func("cos", vec![x])]),
2006            ]),
2007            p.pow(x, p.integer(-2_i32)),
2008        ]);
2009        let got = limit(
2010            simplify(half, &p).value,
2011            x,
2012            p.integer(0_i32),
2013            LimitDirection::Bidirectional,
2014            &p,
2015        )
2016        .unwrap();
2017        assert_eq!(got, p.rational(1, 2));
2018    }
2019
2020    /// An oscillating factor has no settled one-sided estimate, so the guard
2021    /// must stay silent rather than refuse a correct answer.
2022    ///
2023    /// `x·sin(1/x) → 0` at 0 by squeeze, and the samples swing wildly, so this
2024    /// pins that "no verdict" is distinct from "contradiction".
2025    #[test]
2026    fn oscillation_does_not_trigger_a_false_refusal() {
2027        let p = ExprPool::new();
2028        let x = p.symbol("x", Domain::Real);
2029        let ex = p.mul(vec![x, p.func("sin", vec![p.pow(x, p.integer(-1_i32))])]);
2030        let got = limit(ex, x, p.integer(0_i32), LimitDirection::Bidirectional, &p);
2031        assert_eq!(got.unwrap(), p.integer(0_i32));
2032    }
2033
2034    /// A free parameter makes sampling meaningless, so the guard abstains.
2035    #[test]
2036    fn symbolic_parameter_abstains() {
2037        let p = ExprPool::new();
2038        let x = p.symbol("x", Domain::Real);
2039        let a = p.symbol("a", Domain::Real);
2040        assert!(has_free_symbol_besides(p.mul(vec![a, x]), x, &p));
2041        assert!(!has_free_symbol_besides(
2042            p.mul(vec![x, p.func("sin", vec![x])]),
2043            x,
2044            &p
2045        ));
2046    }
2047}