use std::collections::{BTreeMap, HashMap};
use std::fmt;
use rug::{Integer, Rational};
use crate::budget::BudgetError;
use crate::calculus::series::{
enter_coeff_ceiling, expansion_increment, first_indeterminate, is_structural_zero,
local_expansion, SeriesError, MAX_SERIES_POOL_GROWTH,
};
use crate::kernel::{subs, Domain, ExprData, ExprId, ExprPool};
use crate::simplify::simplify;
pub const MAX_RAMIFICATION: u64 = 8;
const MAX_DEPTH: u32 = 24;
const MAX_SERIES_TERMS: u32 = 96;
const MAX_LEAF_RETRIES: u32 = 4;
const MAX_LEAF_ORDER: u32 = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum PuiseuxError {
InvalidOrder,
Diff(String),
NotPuiseux(NotPuiseuxReason),
Exhausted(Option<BudgetError>),
Unverified(UnverifiedReason),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum NotPuiseuxReason {
Logarithmic,
EssentialSingularity,
UnboundedRamification,
UnsupportedForm,
IndeterminateCoefficient,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnverifiedReason {
DecayTooSlow,
NotEvaluable,
PowerCheckDisagrees,
}
impl fmt::Display for PuiseuxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PuiseuxError::InvalidOrder => {
write!(
f,
"puiseux order must be >= 1 (exclusive truncation exponent in h)"
)
}
PuiseuxError::Diff(e) => write!(f, "{e}"),
PuiseuxError::NotPuiseux(r) => write!(
f,
"no Puiseux expansion at this point: {}",
match r {
NotPuiseuxReason::Logarithmic =>
"the expansion contains a logarithm of a quantity vanishing at the \
point (log x, sqrt(x)*log x). That is a Puiseux-log / transseries \
term, not a Puiseux series, and this engine has no representation \
for one",
NotPuiseuxReason::EssentialSingularity =>
"a function head is applied to something with a pole at the point \
(exp(1/x), sin(1/x)); no truncation exponent bounds the remainder",
NotPuiseuxReason::UnboundedRamification =>
"the ramification index exceeds the largest this engine can verify \
an expansion at",
NotPuiseuxReason::UnsupportedForm =>
"the expression contains a shape the expander does not implement \
(a variable exponent, a piecewise, an unexpandable head)",
NotPuiseuxReason::IndeterminateCoefficient =>
"a coefficient is an indeterminate form (0/0, 0^-1, log 0) rather \
than a number",
}
),
PuiseuxError::Exhausted(_) => write!(
f,
"the Puiseux expansion did not reach the requested order within the work \
ceiling; refusing to return a shorter series under the requested O(.) label"
),
PuiseuxError::Unverified(r) => write!(
f,
"a Puiseux expansion was computed and withheld because it could not be \
verified: {}",
match r {
UnverifiedReason::DecayTooSlow =>
"the truncation residual does not decay at the claimed rate",
UnverifiedReason::NotEvaluable =>
"neither the function nor the series could be evaluated near the \
expansion point, so nothing was measured",
UnverifiedReason::PowerCheckDisagrees =>
"raising the series to the ramification index disagreed with the \
independently computed expansion of the same power",
}
),
}
}
}
impl std::error::Error for PuiseuxError {}
impl crate::errors::AlkahestError for PuiseuxError {
fn code(&self) -> &'static str {
match self {
PuiseuxError::Diff(_) => "E-SERIES-001",
PuiseuxError::InvalidOrder => "E-SERIES-002",
PuiseuxError::Exhausted(_) => "E-SERIES-003",
PuiseuxError::NotPuiseux(_) => "E-SERIES-005",
PuiseuxError::Unverified(_) => "E-SERIES-006",
}
}
fn remediation(&self) -> Option<&'static str> {
match self {
PuiseuxError::Diff(_) => {
Some("ensure all functions are registered primitives with differentiation rules")
}
PuiseuxError::InvalidOrder => {
Some("pass order >= 1 (exclusive truncation exponent in h)")
}
PuiseuxError::Exhausted(_) => Some(
"ask for a lower order, raise the budget, or rewrite the expression so its \
expansion closes",
),
PuiseuxError::NotPuiseux(_) => Some(
"a logarithm of a vanishing quantity (`sqrt(x)*log x`) needs a \
Puiseux-log/transseries type this engine does not have, and an essential \
singularity has no truncation exponent at all: factor the log term out by \
hand, or expand about a nearby regular point. A ramification index past \
the verifiable range is the third reading — a Python `Fraction` exponent \
reaches the kernel through `f64` and is not the fraction you wrote; build \
it with `pow_expr(pool.rational(p, q))`",
),
PuiseuxError::Unverified(_) => Some(
"ask for a lower order, or expand about a point where the function is real \
and its heads have numeric kernels; an unverified expansion is withheld \
rather than returned",
),
}
}
}
impl From<SeriesError> for PuiseuxError {
fn from(e: SeriesError) -> Self {
match e {
SeriesError::Diff(d) => PuiseuxError::Diff(d.to_string()),
SeriesError::InvalidOrder => PuiseuxError::Exhausted(crate::budget::check().err()),
}
}
}
#[derive(Clone, Debug)]
pub struct PuiseuxExpansion {
terms: Vec<(Rational, ExprId)>,
remainder: Rational,
ramification: u64,
h_expr: ExprId,
expr: ExprId,
evidence: Evidence,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct Evidence {
pub rungs: usize,
pub conclusive_rungs: usize,
pub worst_margin: f64,
pub power_check_passed: bool,
}
impl PuiseuxExpansion {
pub fn expr(&self) -> ExprId {
self.expr
}
pub fn increment(&self) -> ExprId {
self.h_expr
}
pub fn ramification(&self) -> u64 {
self.ramification
}
pub fn terms(&self) -> &[(Rational, ExprId)] {
&self.terms
}
pub fn valuation(&self) -> Option<&Rational> {
self.terms.first().map(|(e, _)| e)
}
pub fn remainder_order(&self) -> &Rational {
&self.remainder
}
pub fn evidence(&self) -> Evidence {
self.evidence
}
}
pub fn puiseux_series(
expr: ExprId,
var: ExprId,
point: ExprId,
order: u32,
pool: &ExprPool,
) -> Result<PuiseuxExpansion, PuiseuxError> {
if order == 0 {
return Err(PuiseuxError::InvalidOrder);
}
let ceiling = pool.len().saturating_add(MAX_SERIES_POOL_GROWTH);
let _coeff_ceiling = enter_coeff_ceiling(ceiling);
let _pool_ceiling = enter_pool_ceiling(ceiling);
let xi = pool.symbol("__pxi", Domain::Real);
let mut map = HashMap::new();
map.insert(var, pool.add(vec![point, xi]));
let shifted = subs(expr, &map, pool);
let h_expr = expansion_increment(pool, var, point);
let prec = Rational::from(order);
let mut s = expand(shifted, xi, &prec, pool, 0)?;
if s.prec < prec {
return Err(PuiseuxError::Exhausted(None));
}
s.truncate(&prec);
let ramification = ramification_of(&s.terms);
if ramification > MAX_RAMIFICATION {
return Err(PuiseuxError::NotPuiseux(
NotPuiseuxReason::UnboundedRamification,
));
}
let terms: Vec<(Rational, ExprId)> = s.terms.iter().map(|(e, c)| (e.clone(), *c)).collect();
let evidence = verify(expr, var, point, &terms, &prec, ramification, pool)?;
let out = assemble(&terms, &prec, h_expr, pool);
Ok(PuiseuxExpansion {
terms,
remainder: prec,
ramification,
h_expr,
expr: out,
evidence,
})
}
#[derive(Clone, Debug)]
struct PSeries {
terms: BTreeMap<Rational, ExprId>,
prec: Rational,
}
impl PSeries {
fn empty(prec: Rational) -> Self {
PSeries {
terms: BTreeMap::new(),
prec,
}
}
fn constant(c: ExprId, prec: Rational, pool: &ExprPool) -> Self {
let mut terms = BTreeMap::new();
if !is_structural_zero(c, pool) {
terms.insert(Rational::from(0), c);
}
PSeries { terms, prec }
}
fn val(&self) -> Rational {
self.terms
.keys()
.next()
.cloned()
.unwrap_or_else(|| self.prec.clone())
}
fn lead(&self) -> Option<(Rational, ExprId)> {
self.terms.iter().next().map(|(e, c)| (e.clone(), *c))
}
fn truncate(&mut self, prec: &Rational) {
if *prec < self.prec {
self.prec = prec.clone();
}
let cut = self.prec.clone();
self.terms.retain(|e, _| *e < cut);
}
}
fn qi(n: i32) -> Rational {
Rational::from(n)
}
fn qadd(a: &Rational, b: &Rational) -> Rational {
Rational::from(a + b)
}
fn qsub(a: &Rational, b: &Rational) -> Rational {
Rational::from(a - b)
}
fn qmul(a: &Rational, b: &Rational) -> Rational {
Rational::from(a * b)
}
fn qmin(a: Rational, b: Rational) -> Rational {
if a < b {
a
} else {
b
}
}
fn qceil(r: &Rational) -> Integer {
let (q, rem) = Integer::from(r.numer()).div_rem_floor(Integer::from(r.denom()));
if rem == 0 {
q
} else {
q + 1
}
}
fn ramification_of(terms: &BTreeMap<Rational, ExprId>) -> u64 {
let mut e: u64 = 1;
for k in terms.keys() {
let d = k.denom().to_u64().unwrap_or(u64::MAX);
e = lcm_u64(e, d);
if e >= u64::MAX / 2 {
return u64::MAX;
}
}
e
}
fn lcm_u64(a: u64, b: u64) -> u64 {
if a == 0 || b == 0 {
return 0;
}
let g = gcd_u64(a, b);
(a / g).saturating_mul(b)
}
fn gcd_u64(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
fn simp(id: ExprId, pool: &ExprPool) -> ExprId {
simplify(id, pool).value
}
fn insert_term(terms: &mut BTreeMap<Rational, ExprId>, e: Rational, c: ExprId, pool: &ExprPool) {
if is_structural_zero(c, pool) {
return;
}
match terms.entry(e) {
std::collections::btree_map::Entry::Occupied(mut o) => {
let merged = simp(pool.add(vec![*o.get(), c]), pool);
if is_structural_zero(merged, pool) {
o.remove();
} else {
o.insert(merged);
}
}
std::collections::btree_map::Entry::Vacant(v) => {
v.insert(c);
}
}
}
fn p_add(a: &PSeries, b: &PSeries, pool: &ExprPool) -> PSeries {
let prec = qmin(a.prec.clone(), b.prec.clone());
let mut terms = BTreeMap::new();
for (e, c) in a.terms.iter().chain(b.terms.iter()) {
if *e < prec {
insert_term(&mut terms, e.clone(), *c, pool);
}
}
PSeries { terms, prec }
}
fn p_mul(a: &PSeries, b: &PSeries, pool: &ExprPool) -> PSeries {
let prec = qmin(qadd(&a.prec, &b.val()), qadd(&b.prec, &a.val()));
let mut terms = BTreeMap::new();
for (ea, ca) in &a.terms {
for (eb, cb) in &b.terms {
let e = qadd(ea, eb);
if e < prec {
let c = simp(pool.mul(vec![*ca, *cb]), pool);
insert_term(&mut terms, e, c, pool);
}
}
}
PSeries { terms, prec }
}
fn p_shift_scale(a: &PSeries, shift: &Rational, k: &Rational, pool: &ExprPool) -> PSeries {
let kk = pool.rational(k.numer().clone(), k.denom().clone());
let mut terms = BTreeMap::new();
for (e, c) in &a.terms {
let c2 = simp(pool.mul(vec![*c, kk]), pool);
insert_term(&mut terms, qadd(e, shift), c2, pool);
}
PSeries {
terms,
prec: qadd(&a.prec, shift),
}
}
fn binomial_rational(alpha: &Rational, k: u32) -> Rational {
let mut acc = Rational::from(1);
for i in 0..k {
acc *= qsub(alpha, &Rational::from(i));
}
let mut f = Integer::from(1);
for i in 2..=k {
f *= i;
}
acc /= Rational::from(f);
acc
}
fn p_pow_rational(
a: &PSeries,
alpha: &Rational,
prec: &Rational,
pool: &ExprPool,
) -> Result<PSeries, PuiseuxError> {
let Some((v, c0)) = a.lead() else {
if *alpha.denom() == 1 && *alpha.numer() > 0 {
let n = alpha.numer().to_u32().unwrap_or(1);
return Ok(PSeries::empty(qmul(&a.prec, &Rational::from(n))));
}
return Err(PuiseuxError::Exhausted(None));
};
let lead_exp = qmul(&v, alpha);
let target = qsub(prec, &lead_exp);
if target <= qi(0) {
return Ok(PSeries::empty(lead_exp));
}
let inv_c0 = simp(pool.pow(c0, pool.integer(-1_i32)), pool);
let mut u = PSeries::empty(qsub(&a.prec, &v));
for (e, c) in a.terms.iter().skip(1) {
let c2 = simp(pool.mul(vec![*c, inv_c0]), pool);
insert_term(&mut u.terms, qsub(e, &v), c2, pool);
}
let wprec = qmin(target, u.prec.clone());
if wprec <= qi(0) {
return Ok(PSeries::empty(lead_exp));
}
let vu = u.val();
if vu <= qi(0) {
return Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm));
}
let kmax_i = qceil(&Rational::from(&wprec / &vu));
let kmax = kmax_i.to_u32().unwrap_or(u32::MAX);
if kmax > MAX_SERIES_TERMS {
return Err(PuiseuxError::Exhausted(None));
}
let one = pool.integer(1_i32);
let mut w = PSeries::constant(one, wprec.clone(), pool);
let mut upow = PSeries::constant(one, wprec.clone(), pool);
for k in 1..kmax.max(1) {
check_work(pool)?;
upow = p_mul(&upow, &u, pool);
upow.truncate(&wprec);
if upow.terms.is_empty() {
break;
}
let bk = binomial_rational(alpha, k);
if bk != 0 {
let scaled = p_shift_scale(&upow, &qi(0), &bk, pool);
w = p_add(&w, &scaled, pool);
}
}
w.truncate(&wprec);
let alpha_e = pool.rational(alpha.numer().clone(), alpha.denom().clone());
let scale = simp(pool.pow(c0, alpha_e), pool);
let mut terms = BTreeMap::new();
for (e, c) in &w.terms {
let c2 = simp(pool.mul(vec![*c, scale]), pool);
insert_term(&mut terms, qadd(e, &lead_exp), c2, pool);
}
Ok(PSeries {
terms,
prec: qadd(&lead_exp, &w.prec),
})
}
fn check_work(pool: &ExprPool) -> Result<(), PuiseuxError> {
if let Err(b) = crate::budget::check() {
return Err(PuiseuxError::Exhausted(Some(b)));
}
if pool.len() > pool_ceiling() {
return Err(PuiseuxError::Exhausted(None));
}
Ok(())
}
thread_local! {
static POOL_CEILING: std::cell::Cell<usize> = const { std::cell::Cell::new(usize::MAX) };
}
fn pool_ceiling() -> usize {
POOL_CEILING.with(|c| c.get())
}
struct PoolCeiling(usize);
impl Drop for PoolCeiling {
fn drop(&mut self) {
POOL_CEILING.with(|c| c.set(self.0));
}
}
fn enter_pool_ceiling(ceiling: usize) -> PoolCeiling {
POOL_CEILING.with(|c| {
let prev = c.get();
c.set(ceiling);
PoolCeiling(prev)
})
}
fn depends_on(expr: ExprId, var: ExprId, pool: &ExprPool) -> bool {
crate::kernel::subs::mentions_var(expr, var, pool)
}
fn as_rational(id: ExprId, pool: &ExprPool) -> Option<Rational> {
pool.with(id, |d| match d {
ExprData::Integer(n) => Some(Rational::from(n.0.clone())),
ExprData::Rational(r) => Some(r.0.clone()),
ExprData::Float(f) => f.inner.to_rational(),
_ => None,
})
}
fn leaf_expand(
node: ExprId,
xi: ExprId,
target: &Rational,
pool: &ExprPool,
) -> Result<Option<PSeries>, PuiseuxError> {
let zero = pool.integer(0_i32);
let mut order = qceil(target).to_u32().unwrap_or(MAX_LEAF_ORDER).max(1);
for _ in 0..MAX_LEAF_RETRIES {
check_work(pool)?;
if order > MAX_LEAF_ORDER {
return Err(PuiseuxError::Exhausted(None));
}
let le = match local_expansion(node, xi, zero, order, pool) {
Ok(le) => le,
Err(SeriesError::Diff(_)) => return Ok(None),
Err(e) => return Err(e.into()),
};
if first_indeterminate(&le.coeffs, pool).is_some() {
return Ok(None);
}
let achieved = Rational::from(le.valuation) + Rational::from(le.coeffs.len() as u32);
let mut terms = BTreeMap::new();
for (k, c) in le.coeffs.iter().enumerate() {
let e = Rational::from(le.valuation) + Rational::from(k as u32);
insert_term(&mut terms, e, *c, pool);
}
if achieved >= *target || le.coeffs.is_empty() {
return Ok(Some(PSeries {
terms,
prec: achieved,
}));
}
let deficit = qceil(&qsub(target, &achieved)).to_u32().unwrap_or(1).max(1);
order = order.saturating_add(deficit);
}
Err(PuiseuxError::Exhausted(None))
}
fn expand(
e: ExprId,
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
check_work(pool)?;
if depth > MAX_DEPTH {
return Err(PuiseuxError::Exhausted(None));
}
if !depends_on(e, xi, pool) {
return Ok(PSeries::constant(simp(e, pool), prec.clone(), pool));
}
if e == xi {
let mut terms = BTreeMap::new();
terms.insert(qi(1), pool.integer(1_i32));
return Ok(PSeries {
terms,
prec: prec.clone(),
});
}
match pool.get(e) {
ExprData::Add(args) => {
let mut acc = PSeries::constant(pool.integer(0_i32), prec.clone(), pool);
for a in args {
let s = expand(a, xi, prec, pool, depth + 1)?;
acc = p_add(&acc, &s, pool);
}
Ok(acc)
}
ExprData::Mul(args) => expand_mul(&args, xi, prec, pool, depth),
ExprData::Pow { base, exp } => expand_pow(e, base, exp, xi, prec, pool, depth),
ExprData::Func { ref name, ref args } => expand_func(e, name, args, xi, prec, pool, depth),
ExprData::Symbol { .. } => {
Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm))
}
_ => Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm)),
}
}
fn expand_mul(
args: &[ExprId],
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
let mut parts: Vec<PSeries> = Vec::with_capacity(args.len());
for &a in args {
parts.push(expand(a, xi, prec, pool, depth + 1)?);
}
for _ in 0..3 {
let vals: Vec<Rational> = parts.iter().map(|p| p.val()).collect();
let mut total = qi(0);
for v in &vals {
total = qadd(&total, v);
}
let mut changed = false;
for i in 0..parts.len() {
let others = qsub(&total, &vals[i]);
let needed = qsub(prec, &others);
if needed > parts[i].prec {
parts[i] = expand(args[i], xi, &needed, pool, depth + 1)?;
changed = true;
}
}
if !changed {
break;
}
}
let mut acc = PSeries::constant(pool.integer(1_i32), prec.clone(), pool);
let mut iter = parts.into_iter();
if let Some(first) = iter.next() {
acc = first;
}
for p in iter {
check_work(pool)?;
acc = p_mul(&acc, &p, pool);
}
Ok(acc)
}
fn expand_pow(
node: ExprId,
base: ExprId,
exp: ExprId,
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
if depends_on(exp, xi, pool) {
return match leaf_expand(node, xi, prec, pool)? {
Some(s) => Ok(s),
None => Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm)),
};
}
let Some(alpha) = as_rational(exp, pool) else {
return match leaf_expand(node, xi, prec, pool)? {
Some(s) => Ok(s),
None => Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm)),
};
};
if let Some(s) = leaf_expand(node, xi, prec, pool)? {
return Ok(s);
}
expand_rational_power(base, &alpha, xi, prec, pool, depth)
}
fn expand_rational_power(
base: ExprId,
alpha: &Rational,
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
let mut b = expand(base, xi, prec, pool, depth + 1)?;
let v = b.val();
let needed = qadd(prec, &qmul(&v, &qsub(&qi(1), alpha)));
if needed > b.prec {
b = expand(base, xi, &needed, pool, depth + 1)?;
}
p_pow_rational(&b, alpha, prec, pool)
}
fn expand_func(
node: ExprId,
name: &str,
args: &[ExprId],
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
if let Some(s) = leaf_expand(node, xi, prec, pool)? {
return Ok(s);
}
if args.len() != 1 {
return Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm));
}
if name == "sqrt" {
return expand_rational_power(args[0], &Rational::from((1, 2)), xi, prec, pool, depth);
}
if name == "cbrt" {
return expand_rational_power(args[0], &Rational::from((1, 3)), xi, prec, pool, depth);
}
compose_head(name, args[0], xi, prec, pool, depth)
}
fn compose_head(
name: &str,
arg: ExprId,
xi: ExprId,
prec: &Rational,
pool: &ExprPool,
depth: u32,
) -> Result<PSeries, PuiseuxError> {
let inner = expand(arg, xi, prec, pool, depth + 1)?;
if inner.terms.keys().next().is_some_and(|e| *e < qi(0)) {
return Err(PuiseuxError::NotPuiseux(
NotPuiseuxReason::EssentialSingularity,
));
}
let zero = pool.integer(0_i32);
let c = inner.terms.get(&qi(0)).copied().unwrap_or(zero);
let mut rest = inner.clone();
rest.terms.remove(&qi(0));
if rest.terms.is_empty() {
let val = simp(pool.func(name, vec![c]), pool);
let p = qmin(prec.clone(), inner.prec.clone());
return Ok(PSeries::constant(val, p, pool));
}
let vr = rest.val();
if vr <= qi(0) {
return Err(PuiseuxError::NotPuiseux(NotPuiseuxReason::UnsupportedForm));
}
let s = pool.symbol("__pcomp", Domain::Real);
let shifted_arg = simp(pool.add(vec![c, s]), pool);
let g = pool.func(name, vec![shifted_arg]);
let kmax_r = Rational::from(&qmin(prec.clone(), rest.prec.clone()) / &vr);
let kmax = qceil(&kmax_r).to_u32().unwrap_or(u32::MAX).max(1);
if kmax > MAX_SERIES_TERMS {
return Err(PuiseuxError::Exhausted(None));
}
let le = local_expansion(g, s, zero, kmax, pool)?;
if let Some(idx) = first_indeterminate(&le.coeffs, pool) {
let reason = if name == "log" && idx == 0 {
NotPuiseuxReason::Logarithmic
} else {
NotPuiseuxReason::IndeterminateCoefficient
};
return Err(PuiseuxError::NotPuiseux(reason));
}
if le.coeffs.is_empty() {
return Err(PuiseuxError::Exhausted(None));
}
let head_prec = qmul(&vr, &Rational::from(le.coeffs.len() as u32));
let mut acc = PSeries::empty(head_prec);
for &t in le.coeffs.iter().rev() {
check_work(pool)?;
acc = p_mul(&acc, &rest, pool);
acc = p_add(&acc, &PSeries::constant(t, acc.prec.clone(), pool), pool);
}
if le.valuation != 0 {
let shift = Rational::from(le.valuation);
let pw = p_pow_rational(&rest, &shift, prec, pool)?;
acc = p_mul(&acc, &pw, pool);
}
Ok(acc)
}
fn pow_term(h: ExprId, e: &Rational, pool: &ExprPool) -> ExprId {
if *e == 0 {
pool.integer(1_i32)
} else if *e == 1 {
h
} else if *e.denom() == 1 {
pool.pow(h, pool.integer(e.numer().clone()))
} else {
pool.pow(h, pool.rational(e.numer().clone(), e.denom().clone()))
}
}
fn assemble(terms: &[(Rational, ExprId)], order: &Rational, h: ExprId, pool: &ExprPool) -> ExprId {
let mut out = Vec::with_capacity(terms.len() + 1);
for (e, c) in terms {
out.push(pool.mul(vec![*c, pow_term(h, e, pool)]));
}
out.push(pool.big_o(pow_term(h, order, pool)));
pool.add(out)
}
const RESIDUAL_TARGETS: [f64; 6] = [1e-3, 1e-5, 1e-7, 1e-9, 1e-10, 1e-11];
const FALLBACK_HS: [f64; 5] = [0.25, 0.0625, 0.015625, 0.00390625, 0.0009765625];
const MAX_SAMPLE_H: f64 = 0.25;
const NOISE_REL: f64 = 1e-11;
const FIT_MARGIN: f64 = 100.0;
const PARAM_SAMPLES: [[f64; 3]; 2] = [
[0.6180339887498949, 1.3195079107728942, 2.23606797749979],
[1.7320508075688772, 0.4142135623730951, 2.6457513110645907],
];
const MAX_FREE_PARAMS: usize = 3;
fn verify(
orig: ExprId,
var: ExprId,
point: ExprId,
terms: &[(Rational, ExprId)],
order: &Rational,
ramification: u64,
pool: &ExprPool,
) -> Result<Evidence, PuiseuxError> {
let power_check_passed = exact_power_check(orig, var, point, terms, order, ramification, pool)?;
let (rungs, conclusive, worst_margin) =
numeric_ladder(orig, var, point, terms, order, ramification, pool)?;
Ok(Evidence {
rungs,
conclusive_rungs: conclusive,
worst_margin,
power_check_passed,
})
}
fn free_params(expr: ExprId, var: ExprId, pool: &ExprPool, out: &mut Vec<ExprId>) -> bool {
match pool.get(expr) {
ExprData::Integer(_) | ExprData::Rational(_) | ExprData::Float(_) => true,
ExprData::Symbol { .. } => {
if expr != var && !out.contains(&expr) {
out.push(expr);
}
true
}
ExprData::Add(xs) | ExprData::Mul(xs) => xs.iter().all(|&x| free_params(x, var, pool, out)),
ExprData::Pow { base, exp } => {
free_params(base, var, pool, out) && free_params(exp, var, pool, out)
}
ExprData::Func { args, .. } => args.iter().all(|&a| free_params(a, var, pool, out)),
_ => false,
}
}
enum Rung {
AtFloor,
Fitted(f64),
Unevaluable,
Fails,
}
fn numeric_ladder(
orig: ExprId,
var: ExprId,
point: ExprId,
terms: &[(Rational, ExprId)],
order: &Rational,
ramification: u64,
pool: &ExprPool,
) -> Result<(usize, usize, f64), PuiseuxError> {
let empty = HashMap::new();
let Some(a) = crate::jit::eval_interp(point, &empty, pool) else {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
};
if !a.is_finite() {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
}
let mut params = Vec::new();
if !free_params(orig, var, pool, &mut params) || params.len() > MAX_FREE_PARAMS {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
}
let assignments: Vec<Vec<f64>> = if params.is_empty() {
vec![Vec::new()]
} else {
PARAM_SAMPLES
.iter()
.map(|row| (0..params.len()).map(|i| row[i]).collect())
.collect()
};
let tol = decay_tolerance(ramification);
let mut rungs = 0usize;
let mut conclusive = 0usize;
let mut worst_margin = f64::INFINITY;
for assign in &assignments {
let mut env: HashMap<ExprId, f64> = HashMap::new();
for (p, v) in params.iter().zip(assign.iter()) {
env.insert(*p, *v);
}
for k in 0..=terms.len() {
let claimed = if k < terms.len() {
terms[k].0.to_f64()
} else {
order.to_f64()
};
if !claimed.is_finite() {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
}
rungs += 1;
let outcome = measure_rung(orig, var, a, &terms[..k], claimed, &env, pool);
if k == terms.len() && matches!(outcome, Rung::Unevaluable) {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
}
match outcome {
Rung::Fails => {
return Err(PuiseuxError::Unverified(UnverifiedReason::DecayTooSlow))
}
Rung::Fitted(obs) => {
let margin = obs - claimed;
if margin < -tol {
return Err(PuiseuxError::Unverified(UnverifiedReason::DecayTooSlow));
}
worst_margin = worst_margin.min(margin);
conclusive += 1;
}
Rung::AtFloor | Rung::Unevaluable => {}
}
}
}
if conclusive == 0 {
return Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable));
}
if !worst_margin.is_finite() {
worst_margin = 0.0;
}
Ok((rungs, conclusive, worst_margin))
}
fn sample_hs(gamma: f64) -> Vec<f64> {
if gamma >= 0.5 {
let mut hs: Vec<f64> = RESIDUAL_TARGETS
.iter()
.map(|t| t.powf(1.0 / gamma).min(MAX_SAMPLE_H))
.collect();
hs.dedup_by(|a, b| (*a - *b).abs() <= f64::EPSILON * a.abs().max(1.0));
if hs.len() >= 2 {
return hs;
}
}
FALLBACK_HS.to_vec()
}
fn measure_rung(
orig: ExprId,
var: ExprId,
a: f64,
prefix: &[(Rational, ExprId)],
claimed: f64,
env: &HashMap<ExprId, f64>,
pool: &ExprPool,
) -> Rung {
let hs = sample_hs(claimed);
let mut pts: Vec<(f64, f64, f64)> = Vec::with_capacity(hs.len());
for &h in &hs {
if !(h > 0.0 && h.is_finite()) {
continue;
}
let mut e2 = env.clone();
e2.insert(var, a + h);
let Some(fv) = crate::jit::eval_interp(orig, &e2, pool) else {
continue;
};
if !fv.is_finite() {
continue;
}
let Some((sv, scale)) = eval_terms(prefix, h, env, pool) else {
continue;
};
let r = (fv - sv).abs();
if !r.is_finite() {
continue;
}
let floor = NOISE_REL * scale.max(fv.abs()).max(1.0);
pts.push((h, r, floor));
}
if pts.len() < 2 {
return Rung::Unevaluable;
}
let resolved: Vec<bool> = pts.iter().map(|(_, r, f)| r > f).collect();
let first_unresolved = resolved.iter().position(|b| !b).unwrap_or(pts.len());
if claimed > 0.0 && resolved[first_unresolved..].iter().any(|b| *b) {
return Rung::Fails;
}
let usable = if claimed > 0.0 {
&pts[..first_unresolved]
} else {
&pts[..]
};
if usable.len() < 2 {
return Rung::AtFloor;
}
let good: Vec<&(f64, f64, f64)> = usable
.iter()
.filter(|(_, r, f)| *r > FIT_MARGIN * f)
.collect();
let (h0, r0, h1, r1) = if good.len() >= 2 {
let n = good.len();
(good[n - 2].0, good[n - 2].1, good[n - 1].0, good[n - 1].1)
} else {
let n = usable.len();
(usable[0].0, usable[0].1, usable[n - 1].0, usable[n - 1].1)
};
if !(r0 > 0.0 && r1 > 0.0 && h0 > h1) {
return Rung::AtFloor;
}
let observed = (r0 / r1).ln() / (h0 / h1).ln();
if !observed.is_finite() {
return Rung::AtFloor;
}
Rung::Fitted(observed)
}
fn decay_tolerance(ramification: u64) -> f64 {
let e = ramification.max(1) as f64;
(0.55 / e).min(0.4)
}
fn eval_terms(
terms: &[(Rational, ExprId)],
h: f64,
env: &HashMap<ExprId, f64>,
pool: &ExprPool,
) -> Option<(f64, f64)> {
let mut sum = 0.0f64;
let mut scale = 0.0f64;
for (e, c) in terms {
let cv = crate::jit::eval_interp(*c, env, pool)?;
if !cv.is_finite() {
return None;
}
let t = cv * h.powf(e.to_f64());
if !t.is_finite() {
return None;
}
sum += t;
scale = scale.max(t.abs());
}
Some((sum, scale))
}
fn raise_to(expr: ExprId, e: &Rational, pool: &ExprPool) -> ExprId {
match pool.get(expr) {
ExprData::Mul(args) => pool.mul(args.iter().map(|&a| raise_to(a, e, pool)).collect()),
ExprData::Pow { base, exp } => match as_rational(exp, pool) {
Some(p) => {
let ne = qmul(&p, e);
raise_to_leaf(base, &ne, pool)
}
None => raise_to_leaf(expr, e, pool),
},
ExprData::Func { ref name, ref args } if name == "sqrt" && args.len() == 1 => {
let half = Rational::from((1, 2));
raise_to_leaf(args[0], &qmul(e, &half), pool)
}
_ => raise_to_leaf(expr, e, pool),
}
}
fn raise_to_leaf(base: ExprId, e: &Rational, pool: &ExprPool) -> ExprId {
if *e == 1 {
return base;
}
if *e.denom() == 1 {
pool.pow(base, pool.integer(e.numer().clone()))
} else {
pool.pow(base, pool.rational(e.numer().clone(), e.denom().clone()))
}
}
fn exact_power_check(
orig: ExprId,
var: ExprId,
point: ExprId,
terms: &[(Rational, ExprId)],
order: &Rational,
ramification: u64,
pool: &ExprPool,
) -> Result<bool, PuiseuxError> {
if ramification <= 1 || ramification > MAX_RAMIFICATION {
return Ok(false);
}
let e = ramification as i64;
let Some(lead) = terms.first().map(|(x, _)| x.clone()) else {
return Ok(false);
};
let mut s = PSeries::empty(order.clone());
for (ex, c) in terms {
s.terms.insert(ex.clone(), *c);
}
let e_q = Rational::from(e);
let powered = match p_pow_rational(&s, &e_q, &qmul(order, &e_q), pool) {
Ok(p) => p,
Err(_) => return Ok(false),
};
let xi = pool.symbol("__pxi", Domain::Real);
let mut map = HashMap::new();
map.insert(var, pool.add(vec![point, xi]));
let shifted = subs(orig, &map, pool);
let fe = simp(raise_to(shifted, &e_q, pool), pool);
let mut cmp_prec = qmin(powered.prec.clone(), qmul(order, &e_q));
let Ok(Some(reference)) = leaf_expand(fe, xi, &cmp_prec, pool) else {
return Ok(false);
};
cmp_prec = qmin(cmp_prec, reference.prec.clone());
if cmp_prec <= qmul(&lead, &e_q) {
return Ok(false);
}
let mut keys: Vec<Rational> = powered
.terms
.keys()
.chain(reference.terms.keys())
.filter(|k| **k < cmp_prec)
.cloned()
.collect();
keys.sort();
keys.dedup();
let zero = pool.integer(0_i32);
let empty = HashMap::new();
for k in keys {
let x = powered.terms.get(&k).copied().unwrap_or(zero);
let y = reference.terms.get(&k).copied().unwrap_or(zero);
let diff = simp(
pool.add(vec![x, pool.mul(vec![pool.integer(-1_i32), y])]),
pool,
);
if is_structural_zero(diff, pool) {
continue;
}
match crate::jit::eval_interp(diff, &empty, pool) {
Some(v) if v.abs() > 1e-9 => {
return Err(PuiseuxError::Unverified(
UnverifiedReason::PowerCheckDisagrees,
));
}
Some(_) => {}
None => return Ok(false),
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calculus::series::series;
use crate::errors::AlkahestError;
fn pool_with_x() -> (ExprPool, ExprId) {
let p = ExprPool::new();
let x = p.symbol("x", Domain::Real);
(p, x)
}
fn coeffs(px: &PuiseuxExpansion, pool: &ExprPool) -> Vec<(Rational, f64)> {
let env = HashMap::new();
px.terms()
.iter()
.map(|(e, c)| {
(
e.clone(),
crate::jit::eval_interp(*c, &env, pool).unwrap_or(f64::NAN),
)
})
.collect()
}
fn assert_terms(got: &[(Rational, f64)], want: &[((i32, u32), f64)]) {
assert_eq!(got.len(), want.len(), "term count: {got:?} vs {want:?}");
for (g, w) in got.iter().zip(want.iter()) {
assert_eq!(
g.0,
Rational::from((w.0 .0, w.0 .1)),
"exponent: {got:?} vs {want:?}"
);
assert!(
(g.1 - w.1).abs() <= 1e-12 * w.1.abs().max(1.0),
"coefficient: {got:?} vs {want:?}"
);
}
}
#[test]
fn sqrt_x_is_a_single_half_integer_term() {
let (p, x) = pool_with_x();
let e = p.func("sqrt", vec![x]);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((1, 2), 1.0)]);
assert_eq!(*px.remainder_order(), Rational::from(5));
assert!(series(e, x, p.integer(0), 5, &p).is_err());
}
#[test]
fn x_to_the_three_halves() {
let (p, x) = pool_with_x();
let e = p.pow(x, p.rational(3, 2));
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((3, 2), 1.0)]);
}
#[test]
fn sqrt_of_a_simple_zero() {
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
let e = p.func("sqrt", vec![s]);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(
&coeffs(&px, &p),
&[((1, 2), 1.0), ((5, 2), -1.0 / 12.0), ((9, 2), 1.0 / 1440.0)],
);
assert!(px.evidence().power_check_passed);
}
#[test]
fn half_power_times_an_analytic_factor() {
let (p, x) = pool_with_x();
let h = p.pow(x, p.rational(1, 2));
let s = p.func("sin", vec![x]);
let e = p.mul(vec![h, s]);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((3, 2), 1.0), ((7, 2), -1.0 / 6.0)]);
}
#[test]
fn cube_root_of_a_simple_zero_has_ramification_three() {
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
let e = p.pow(s, p.rational(1, 3));
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 3);
assert_terms(
&coeffs(&px, &p),
&[
((1, 3), 1.0),
((7, 3), -1.0 / 18.0),
((13, 3), -1.0 / 3240.0),
],
);
}
#[test]
fn an_analytic_head_composed_with_a_ramified_argument() {
let (p, x) = pool_with_x();
let s = p.func("sqrt", vec![x]);
let e = p.func("sin", vec![s]);
let px = puiseux_series(e, x, p.integer(0), 4, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(
&coeffs(&px, &p),
&[
((1, 2), 1.0),
((3, 2), -1.0 / 6.0),
((5, 2), 1.0 / 120.0),
((7, 2), -1.0 / 5040.0),
],
);
}
#[test]
fn a_negative_fractional_valuation() {
let (p, x) = pool_with_x();
let e = p.pow(x, p.rational(-1, 2));
let px = puiseux_series(e, x, p.integer(0), 3, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((-1, 2), 1.0)]);
assert_eq!(*px.valuation().unwrap(), Rational::from((-1, 2)));
}
#[test]
fn expansion_about_a_nonzero_point() {
let (p, x) = pool_with_x();
let inner = p.add(vec![x, p.integer(-1)]);
let e = p.func("sqrt", vec![inner]);
let px = puiseux_series(e, x, p.integer(1), 3, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((1, 2), 1.0)]);
}
#[test]
fn the_radical_series_cannot_finish_expands_through_the_pole() {
let p = ExprPool::new();
let t = p.symbol("t", Domain::Real);
let inner = p.add(vec![p.pow(t, p.integer(-2)), p.pow(t, p.integer(-1))]);
let e = p.func("sqrt", vec![inner]);
assert!(series(e, t, p.integer(0), 8, &p).is_err());
let px = puiseux_series(e, t, p.integer(0), 8, &p).unwrap();
assert_eq!(px.ramification(), 1);
assert_terms(
&coeffs(&px, &p),
&[
((-1, 1), 1.0),
((0, 1), 0.5),
((1, 1), -0.125),
((2, 1), 1.0 / 16.0),
((3, 1), -5.0 / 128.0),
((4, 1), 7.0 / 256.0),
((5, 1), -21.0 / 1024.0),
((6, 1), 33.0 / 2048.0),
((7, 1), -429.0 / 32768.0),
],
);
}
#[test]
fn a_square_root_of_an_even_order_zero_is_unramified() {
let (p, x) = pool_with_x();
let a = p.add(vec![p.pow(x, p.integer(2)), p.pow(x, p.integer(3))]);
let e = p.func("sqrt", vec![a]);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 1);
assert_terms(
&coeffs(&px, &p),
&[
((1, 1), 1.0),
((2, 1), 0.5),
((3, 1), -0.125),
((4, 1), 1.0 / 16.0),
],
);
}
#[test]
fn ramification_reduces_when_the_valuation_shares_a_factor() {
let (p, x) = pool_with_x();
let a = p.pow(x, p.integer(2));
let e = p.pow(a, p.rational(1, 4));
let px = puiseux_series(e, x, p.integer(0), 3, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_terms(&coeffs(&px, &p), &[((1, 2), 1.0)]);
}
#[test]
fn an_unramified_radical_with_irrational_coefficients() {
let (p, x) = pool_with_x();
let c = p.func("cos", vec![x]);
let a = p.add(vec![p.integer(1), p.mul(vec![p.integer(-1), c])]);
let e = p.func("sqrt", vec![a]);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
assert_eq!(px.ramification(), 1);
let inv_sqrt2 = 1.0 / 2.0f64.sqrt();
assert_terms(
&coeffs(&px, &p),
&[((1, 1), inv_sqrt2), ((3, 1), -inv_sqrt2 / 24.0)],
);
}
fn refuses(expr: ExprId, x: ExprId, order: u32, p: &ExprPool) -> PuiseuxError {
puiseux_series(expr, x, p.integer(0), order, p)
.err()
.unwrap_or_else(|| panic!("expected a refusal, got an expansion"))
}
#[test]
fn sqrt_times_log_is_refused_as_logarithmic() {
let (p, x) = pool_with_x();
let e = p.mul(vec![p.func("sqrt", vec![x]), p.func("log", vec![x])]);
let err = refuses(e, x, 5, &p);
assert_eq!(
err,
PuiseuxError::NotPuiseux(NotPuiseuxReason::Logarithmic),
"{err}"
);
assert_eq!(err.code(), "E-SERIES-005");
}
#[test]
fn a_bare_logarithm_is_refused() {
let (p, x) = pool_with_x();
let e = p.func("log", vec![x]);
assert_eq!(
refuses(e, x, 4, &p),
PuiseuxError::NotPuiseux(NotPuiseuxReason::Logarithmic)
);
}
#[test]
fn an_essential_singularity_is_refused() {
let (p, x) = pool_with_x();
let inv = p.pow(x, p.integer(-1));
for head in ["exp", "sin", "cos"] {
let e = p.func(head, vec![inv]);
assert_eq!(
refuses(e, x, 4, &p),
PuiseuxError::NotPuiseux(NotPuiseuxReason::EssentialSingularity),
"{head}(1/x)"
);
}
}
#[test]
fn an_essential_singularity_inside_a_product_is_still_refused() {
let (p, x) = pool_with_x();
let inv = p.pow(x, p.integer(-1));
let e = p.mul(vec![p.func("sqrt", vec![x]), p.func("exp", vec![inv])]);
assert_eq!(
refuses(e, x, 4, &p),
PuiseuxError::NotPuiseux(NotPuiseuxReason::EssentialSingularity)
);
}
#[test]
fn ramification_past_the_verifiable_range_is_refused() {
let (p, x) = pool_with_x();
let e = p.pow(x, p.rational(1, 12));
let err = refuses(e, x, 2, &p);
assert_eq!(
err,
PuiseuxError::NotPuiseux(NotPuiseuxReason::UnboundedRamification),
"{err}"
);
let ok = p.pow(x, p.rational(1, 8));
let px = puiseux_series(ok, x, p.integer(0), 2, &p).unwrap();
assert_eq!(px.ramification(), 8);
assert_eq!(px.ramification(), MAX_RAMIFICATION);
}
#[test]
fn a_variable_exponent_is_refused() {
let (p, x) = pool_with_x();
let log_x = p.func("log", vec![x]);
let x_to_x = p.func("exp", vec![p.mul(vec![x, log_x])]);
let err = refuses(x_to_x, x, 3, &p);
assert!(matches!(err, PuiseuxError::NotPuiseux(_)), "{err}");
}
#[test]
fn order_zero_is_a_user_error() {
let (p, x) = pool_with_x();
let e = p.func("sqrt", vec![x]);
let err = puiseux_series(e, x, p.integer(0), 0, &p).unwrap_err();
assert_eq!(err, PuiseuxError::InvalidOrder);
assert_eq!(err.code(), "E-SERIES-002");
}
#[test]
fn a_non_real_branch_is_withheld_rather_than_returned() {
let (p, x) = pool_with_x();
let e = p.func("sqrt", vec![p.mul(vec![p.integer(-1), x])]);
let err = puiseux_series(e, x, p.integer(0), 3, &p);
assert!(
matches!(err, Err(PuiseuxError::Unverified(_))),
"{:?}",
err.map(|s| s.ramification())
);
}
#[test]
fn the_verifier_rejects_a_corrupted_expansion() {
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
let e = p.func("sqrt", vec![s]);
let order = Rational::from(5);
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
let good: Vec<(Rational, ExprId)> = px.terms().to_vec();
verify(e, x, p.integer(0), &good, &order, 2, &p).expect("the true expansion verifies");
for i in 0..good.len() {
for factor in [2_i32, -1, 3] {
let mut bad = good.clone();
bad[i].1 = simp(p.mul(vec![bad[i].1, p.integer(factor)]), &p);
let r = verify(e, x, p.integer(0), &bad, &order, 2, &p);
assert!(
matches!(r, Err(PuiseuxError::Unverified(_))),
"coefficient {i} scaled by {factor} was accepted"
);
}
}
let mut shifted = good.clone();
shifted[0].0 = Rational::from((3, 2));
let r = verify(e, x, p.integer(0), &shifted, &order, 2, &p);
assert!(
matches!(r, Err(PuiseuxError::Unverified(_))),
"a shifted valuation was accepted"
);
let dropped: Vec<(Rational, ExprId)> = good[..good.len() - 1].to_vec();
let r = verify(e, x, p.integer(0), &dropped, &order, 2, &p);
assert!(
matches!(r, Err(PuiseuxError::Unverified(_))),
"a dropped term was accepted under the full O(.) label"
);
}
#[test]
fn a_coefficient_that_cannot_be_valued_is_a_refusal_not_a_pass() {
let (p, x) = pool_with_x();
let e = p.func("sqrt", vec![x]);
let order = Rational::from(4);
let px = puiseux_series(e, x, p.integer(0), 4, &p).unwrap();
let good: Vec<(Rational, ExprId)> = px.terms().to_vec();
verify(e, x, p.integer(0), &good, &order, 2, &p).expect("the true expansion verifies");
let mute = p.func("__no_such_primitive", vec![p.integer(1_i32)]);
let mut opaque = good.clone();
opaque.push((Rational::from((7, 2)), mute));
let r = verify(e, x, p.integer(0), &opaque, &order, 2, &p);
assert!(
matches!(
r,
Err(PuiseuxError::Unverified(UnverifiedReason::NotEvaluable))
),
"an unvaluable trailing coefficient was accepted: {r:?}"
);
}
#[test]
fn every_returned_expansion_has_a_measured_decay_rate() {
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
let cases = [
p.func("sqrt", vec![x]),
p.func("sqrt", vec![s]),
p.pow(s, p.rational(1, 3)),
p.mul(vec![p.pow(x, p.rational(1, 2)), s]),
p.func("sin", vec![p.func("sqrt", vec![x])]),
];
for e in cases {
let px = puiseux_series(e, x, p.integer(0), 5, &p).unwrap();
let ev = px.evidence();
assert!(ev.conclusive_rungs >= 1, "{ev:?} for {}", p.display(e));
assert!(
ev.worst_margin >= -decay_tolerance(px.ramification()),
"{ev:?} for {}",
p.display(e)
);
}
}
#[test]
fn an_analytic_expansion_is_identical_to_the_one_series_returns() {
let (p, x) = pool_with_x();
let cases: Vec<(ExprId, u32)> = vec![
(p.func("sin", vec![x]), 6),
(p.func("cos", vec![x]), 6),
(p.func("exp", vec![x]), 5),
(p.func("sqrt", vec![p.add(vec![p.integer(1), x])]), 5),
(p.func("tan", vec![x]), 6),
(p.pow(p.add(vec![p.integer(1), x]), p.integer(-1)), 5),
(
p.mul(vec![p.func("sin", vec![x]), p.pow(x, p.integer(-1))]),
4,
),
];
for (e, order) in cases {
let s = series(e, x, p.integer(0), order, &p).unwrap();
let px = puiseux_series(e, x, p.integer(0), order, &p).unwrap();
assert_eq!(px.ramification(), 1, "{}", p.display(e));
assert_eq!(
px.expr(),
s.expr(),
"{} : {} vs {}",
p.display(e),
p.display(px.expr()),
p.display(s.expr())
);
}
}
#[test]
fn a_polar_expansion_keeps_more_terms_than_series_labels() {
let (p, x) = pool_with_x();
let e = p.pow(p.func("sin", vec![x]), p.integer(-1));
let px = puiseux_series(e, x, p.integer(0), 4, &p).unwrap();
assert_eq!(px.ramification(), 1);
assert_terms(
&coeffs(&px, &p),
&[((-1, 1), 1.0), ((1, 1), 1.0 / 6.0), ((3, 1), 7.0 / 360.0)],
);
assert_eq!(*px.remainder_order(), Rational::from(4));
}
#[test]
fn a_free_parameter_survives_and_is_checked_at_two_values() {
let (p, x) = pool_with_x();
let k = p.symbol("k", Domain::Real);
let e = p.mul(vec![k, p.func("sqrt", vec![x])]);
let px = puiseux_series(e, x, p.integer(0), 4, &p).unwrap();
assert_eq!(px.ramification(), 2);
assert_eq!(px.terms().len(), 1);
assert_eq!(px.terms()[0].0, Rational::from((1, 2)));
assert_eq!(px.terms()[0].1, k);
assert_eq!(px.evidence().rungs, 4);
}
#[test]
fn a_budget_stops_the_expansion() {
use crate::budget::{self, Budget, BudgetError};
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
let e = p.func("sqrt", vec![s]);
let _guard = budget::enter(Budget::new().with_max_steps(3));
let err = puiseux_series(e, x, p.integer(0), 12, &p).unwrap_err();
assert!(
matches!(
err,
PuiseuxError::Exhausted(Some(BudgetError::Steps { .. }))
),
"{err:?}"
);
assert_eq!(err.code(), "E-SERIES-003");
}
#[test]
fn the_reported_exponents_match_the_reported_lattice_and_order() {
let (p, x) = pool_with_x();
let s = p.func("sin", vec![x]);
for order in 2..=8u32 {
let e = p.func("sqrt", vec![s]);
let px = puiseux_series(e, x, p.integer(0), order, &p).unwrap();
let order_q = Rational::from(order);
assert_eq!(*px.remainder_order(), order_q);
let e_r = px.ramification();
let mut last: Option<Rational> = None;
for (ex, _) in px.terms() {
assert!(*ex < order_q, "order {order}: {ex}");
if let Some(l) = &last {
assert!(ex > l, "order {order}: {ex} after {l}");
}
assert_eq!(e_r % ex.denom().to_u64().unwrap(), 0, "order {order}: {ex}");
last = Some(ex.clone());
}
}
}
}
#[cfg(kani)]
mod verification {
use super::*;
#[kani::proof]
#[kani::unwind(9)]
fn gcd_lcm_small() {
let a: u64 = kani::any_where(|a: &u64| *a < (1 << 5));
let b: u64 = kani::any_where(|b: &u64| *b < (1 << 5));
let g = gcd_u64(a, b);
if a == 0 && b == 0 {
assert_eq!(g, 0);
} else {
assert!(g >= 1 && a % g == 0 && b % g == 0);
}
let l = lcm_u64(a, b);
if a == 0 || b == 0 {
assert_eq!(l, 0);
} else {
assert!(l % a == 0 && l % b == 0 && l <= a * b);
}
}
}