use ocas_domain::{Domain, Rational, RationalDomain};
use ocas_poly::DenseUnivariatePolynomial;
use crate::tower::build::{Tower, tower_diff_kpoly};
use crate::tower::elem::{KElem, KPoly};
type DPoly = DenseUnivariatePolynomial<RationalDomain>;
const MAX_RT_DEGREE: usize = 8;
const MAX_RT_CANDIDATES: usize = 64;
const MAX_CONTENT: i64 = 1_000_000_000;
type RtOutcome<'a> = (Vec<(Rational, KElem)>, KElem, Vec<Atom<'a>>);
pub(crate) fn rothstein_trager<'a>(
ctx: &'a AtomArena<'a>,
tower: &Tower<'a>,
level: usize,
a1: &KPoly,
d1: &KPoly,
) -> Option<RtOutcome<'a>> {
let top = level;
let n = d1.n_vars;
let deg_d = d1.degree()?;
if deg_d == 0 || deg_d > MAX_RT_DEGREE {
return None;
}
let lc = d1.lc();
let (a1m, d1m) = if lc.eq_cross(&KElem::one(n)) {
(a1.clone(), d1.clone())
} else {
let lc_inv = lc.inv()?;
(a1.mul_kelem(&lc_inv), d1.monic())
};
if !d1m.gcd(&a1m).is_one() {
return None;
}
let dd1m = clean_kpoly(&tower_diff_kpoly(
&d1m,
&tower.gens[..level - 1],
&tower.gens[level - 1].dt,
))?;
let r_poly = rt_resultant_polynomial(&a1m, &d1m, &dd1m)?;
let roots = match rational_roots_complete(&r_poly) {
Some(roots) => roots,
None => {
return rothstein_trager_algebraic(ctx, tower, level, &a1m, &d1m, &dd1m, &r_poly);
}
};
let mut v_polys: Vec<KPoly> = Vec::with_capacity(roots.len());
for c in &roots {
let shifted = a1m.sub(&dd1m.mul_kelem(&KElem::from_rational(c, n)));
let v = d1m.gcd(&shifted).monic();
if v.degree()? == 0 {
return None;
}
v_polys.push(v);
}
let mut prod = KPoly::one(top, n);
for v in &v_polys {
prod = prod.mul(v);
}
if !prod.sub(&d1m).is_zero() {
return None;
}
let mut rem = a1m.kelem().div(&d1m.kelem())?;
let mut logs_out: Vec<(Rational, KElem)> = Vec::with_capacity(v_polys.len());
for (c, v) in roots.iter().zip(v_polys.iter()) {
let dv = tower_diff_kpoly(v, &tower.gens[..level - 1], &tower.gens[level - 1].dt);
let term = dv.kelem().div(&v.kelem())?.mul_rational(c);
rem = rem.sub(&term);
logs_out.push((c.clone(), v.kelem()));
}
if rem.num.degree_in(top) > 0 || rem.den.degree_in(top) > 0 {
return None;
}
Some((logs_out, rem, Vec::new()))
}
pub(crate) fn clean_kpoly(p: &KPoly) -> Option<KPoly> {
let top = p.top;
let e = p.kelem();
if e.den.degree_in(top) > 0 {
return None;
}
let out = KPoly::from_sparse(&e.num, top);
let den_inv = KElem::from_poly(e.den.clone()).inv()?;
Some(out.mul_kelem(&den_inv))
}
pub(crate) fn rt_resultant_polynomial(a1: &KPoly, d1: &KPoly, dd1: &KPoly) -> Option<Vec<KElem>> {
let n = d1.n_vars;
let deg_d = d1.degree()?;
let template_deg = a1.degree().unwrap_or(0).max(dd1.degree().unwrap_or(0));
let mut nodes: Vec<i64> = Vec::with_capacity(deg_d + 1);
let mut values: Vec<KElem> = Vec::with_capacity(deg_d + 1);
let mut j: i64 = 0;
while values.len() < deg_d + 1 {
if j > 4 * (deg_d as i64 + 2) {
return None;
}
let u = a1.sub(&dd1.mul_kelem(&KElem::from_rational(&Rational::new(j, 1), n)));
j += 1;
if u.degree() != Some(template_deg) {
continue; }
nodes.push(j - 1);
values.push(u.resultant(d1));
}
let mut out = vec![KElem::zero(n); deg_d + 1];
for (idx, y) in values.iter().enumerate() {
let zj = nodes[idx];
let mut basis = vec![Rational::new(1, 1)];
let mut denom: i64 = 1;
for (i, &zi) in nodes.iter().enumerate() {
if i == idx {
continue;
}
let mut next = vec![RationalDomain.zero(); basis.len() + 1];
for (k, c) in basis.iter().enumerate() {
let term = RationalDomain.mul(c, &Rational::new(zi, 1));
next[k] = RationalDomain.sub(&next[k], &term);
next[k + 1] = RationalDomain.add(&next[k + 1], c);
}
basis = next;
denom = denom.checked_mul(zj - zi)?;
}
for (k, c) in basis.iter().enumerate() {
let scale = RationalDomain.div(c, &Rational::new(denom, 1))?;
out[k] = out[k].add(&y.mul_rational(&scale));
}
}
while out.last().is_some_and(|c| c.is_zero()) {
out.pop();
}
if out.is_empty() {
return None;
}
Some(out)
}
pub(crate) fn rational_root_candidates(r: &[KElem]) -> Option<Vec<Rational>> {
if let Some(qcoeffs) = r
.iter()
.map(|c| c.as_rational())
.collect::<Option<Vec<_>>>()
{
let (roots, _fully_split) =
super::rational::rational_roots(&DPoly::from_coeffs(RationalDomain, qcoeffs))?;
return Some(roots);
}
let coeffs: Vec<(&crate::tower::elem::Sparse, &crate::tower::elem::Sparse)> =
r.iter().map(|e| (&e.num, &e.den)).collect();
let mut lcm: i64 = 1;
for (num, den) in &coeffs {
for c in num.terms_ref().values().chain(den.terms_ref().values()) {
let d = c.denom().to_i64()?;
lcm = num_integer::lcm(lcm, d);
if lcm > MAX_CONTENT {
return None;
}
}
}
let content = |p: &crate::tower::elem::Sparse| -> Option<i64> {
let mut g: i64 = 0;
for c in p.terms_ref().values() {
let v = RationalDomain.mul(c, &Rational::new(lcm, 1));
let v = v.numer().to_i64()?;
g = num_integer::gcd(g, v.checked_abs()?);
}
Some(g)
};
let m = r.len() - 1;
let mut g0 = content(coeffs[0].0)?;
let mut gm = content(coeffs[m].0)?;
for (i, (_, den)) in coeffs.iter().enumerate() {
let cd = content(den)?;
if i != 0 {
g0 = g0.checked_mul(cd)?;
}
if i != m {
gm = gm.checked_mul(cd)?;
}
}
if g0 == 0 || gm == 0 || g0 > MAX_CONTENT || gm > MAX_CONTENT {
return None;
}
let mut candidates: Vec<Rational> = Vec::new();
let divs = |g: i64| -> Vec<i64> {
let mut out = Vec::new();
let mut d = 1i64;
while d <= g / d {
if g % d == 0 {
out.push(d);
if d != g / d {
out.push(g / d);
}
}
d += 1;
}
out
};
for p in divs(g0) {
for q in divs(gm) {
for sign in [1, -1] {
let c = Rational::new(sign * p, q);
if !candidates.contains(&c) {
candidates.push(c);
}
}
}
}
if candidates.len() > MAX_RT_CANDIDATES {
return None;
}
Some(candidates)
}
pub(crate) fn rational_roots_complete(r: &[KElem]) -> Option<Vec<Rational>> {
let mut r: Vec<KElem> = r.to_vec();
while r.first().is_some_and(|c| c.is_zero()) {
r.remove(0);
}
if r.is_empty() {
return Some(Vec::new());
}
let candidates = rational_root_candidates(&r)?;
let mut cur = r.clone();
let mut roots: Vec<Rational> = Vec::new();
for c in candidates {
loop {
let mut acc = cur.last()?.clone();
for i in (0..cur.len() - 1).rev() {
acc = acc.mul_rational(&c).add(&cur[i]);
}
if !acc.is_zero() {
break;
}
let mut quo = vec![KElem::zero(cur[0].n_vars()); cur.len() - 1];
let mut carry = cur.last()?.clone();
for i in (1..cur.len()).rev() {
quo[i - 1] = carry.clone();
carry = cur[i - 1].add(&carry.mul_rational(&c));
}
cur = quo;
if !roots.contains(&c) {
roots.push(c.clone());
}
if cur.is_empty() {
break;
}
}
}
if cur.len() != 1 || cur[0].is_zero() {
return None;
}
Some(roots)
}
use ocas_atom::{Atom, AtomArena};
use ocas_domain::Integer;
#[derive(Clone)]
struct KA {
re: KElem,
im: KElem,
}
struct AlphaField {
sqf: Rational,
n: usize,
}
impl AlphaField {
fn zero(&self) -> KA {
KA {
re: KElem::zero(self.n),
im: KElem::zero(self.n),
}
}
fn one(&self) -> KA {
KA {
re: KElem::one(self.n),
im: KElem::zero(self.n),
}
}
fn embed(&self, e: KElem) -> KA {
KA {
re: e,
im: KElem::zero(self.n),
}
}
fn add(&self, a: &KA, b: &KA) -> KA {
KA {
re: a.re.add(&b.re),
im: a.im.add(&b.im),
}
}
fn neg(&self, a: &KA) -> KA {
KA {
re: a.re.neg(),
im: a.im.neg(),
}
}
fn sub(&self, a: &KA, b: &KA) -> KA {
self.add(a, &self.neg(b))
}
fn mul(&self, a: &KA, b: &KA) -> KA {
let sqf = KElem::from_rational(&self.sqf, self.n);
KA {
re: a.re.mul(&b.re).sub(&a.im.mul(&b.im).mul(&sqf)),
im: a.re.mul(&b.im).add(&a.im.mul(&b.re)),
}
}
fn inv(&self, a: &KA) -> Option<KA> {
let sqf = KElem::from_rational(&self.sqf, self.n);
let norm = a.re.mul(&a.re).add(&a.im.mul(&a.im).mul(&sqf));
let norm_inv = norm.inv()?;
Some(KA {
re: a.re.mul(&norm_inv),
im: a.im.neg().mul(&norm_inv),
})
}
fn div(&self, a: &KA, b: &KA) -> Option<KA> {
Some(self.mul(a, &self.inv(b)?))
}
}
struct KAPoly {
coeffs: Vec<KA>,
}
impl KAPoly {
fn trim(&mut self) {
while let Some(last) = self.coeffs.last() {
if last.re.is_zero() && last.im.is_zero() {
self.coeffs.pop();
} else {
break;
}
}
}
fn degree(&self) -> Option<usize> {
self.coeffs.len().checked_sub(1)
}
fn is_zero(&self) -> bool {
self.coeffs.is_empty()
}
fn lc(&self) -> Option<&KA> {
self.coeffs.last()
}
fn div_rem(f: &AlphaField, a: &Self, b: &Self) -> Option<(Self, Self)> {
if b.is_zero() {
return None;
}
let mut rem = a.coeffs.clone();
let mut quo: Vec<KA> = Vec::new();
let b_deg = b.degree()?;
while let Some(r_deg) = rem.len().checked_sub(1) {
if r_deg < b_deg {
break;
}
let lc_r = rem.last().expect("nonempty");
if lc_r.re.is_zero() && lc_r.im.is_zero() {
rem.pop();
continue;
}
let c = f.div(lc_r, b.lc()?)?;
let k = r_deg - b_deg;
if quo.len() < k + 1 {
quo.resize_with(k + 1, || f.zero());
}
quo[k] = f.add(&quo[k], &c);
for (i, bc) in b.coeffs.iter().enumerate() {
rem[k + i] = f.sub(&rem[k + i], &f.mul(&c, bc));
}
}
let mut q = KAPoly { coeffs: quo };
let mut r = KAPoly { coeffs: rem };
q.trim();
r.trim();
Some((q, r))
}
fn gcd(f: &AlphaField, a: &Self, b: &Self) -> Option<Self> {
let (mut a, mut b) = (a.clone_ref(f), b.clone_ref(f));
while !b.is_zero() {
let (_, r) = Self::div_rem(f, &a, &b)?;
a = b;
b = r;
}
if a.is_zero() {
return Some(f.one_poly());
}
let lc_inv = f.inv(a.lc()?)?;
for c in a.coeffs.iter_mut() {
*c = f.mul(c, &lc_inv);
}
a.trim();
Some(a)
}
fn clone_ref(&self, f: &AlphaField) -> Self {
let _ = f;
KAPoly {
coeffs: self.coeffs.clone(),
}
}
}
impl AlphaField {
fn one_poly(&self) -> KAPoly {
KAPoly {
coeffs: vec![self.one()],
}
}
}
fn rothstein_trager_algebraic<'a>(
ctx: &'a AtomArena<'a>,
tower: &Tower<'a>,
level: usize,
a1m: &KPoly,
d1m: &KPoly,
dd1m: &KPoly,
r_poly: &[KElem],
) -> Option<RtOutcome<'a>> {
let top = level;
let n = d1m.n_vars;
let dom = RationalDomain;
let lc = r_poly.iter().rev().find(|c| !c.is_zero())?;
let mut qcoeffs = Vec::with_capacity(r_poly.len());
for c in r_poly {
qcoeffs.push(c.div(lc)?.as_rational()?);
}
let zpoly =
super::rt_algebraic::integer_primitive(&DPoly::from_coeffs(RationalDomain, qcoeffs))?;
let factors = zpoly.factor();
let mut rat_roots: Vec<Rational> = Vec::new();
let mut quad_roots: Vec<(Rational, Rational, Integer)> = Vec::new(); let mut signature: Option<Integer> = None;
for (fac, _mult) in &factors {
match fac.degree()? {
0 => {}
1 => {
let c0 = fac.coeffs().first()?.to_i64()?;
let c1 = fac.coeffs().get(1)?.to_i64()?;
rat_roots.push(Rational::new(-c0, c1));
}
2 => {
let big_c = fac.coeffs().first()?.clone();
let big_b = fac.coeffs().get(1)?.clone();
let big_a = fac.coeffs().get(2)?.clone();
let delta = &(&big_b * &big_b) - &(Integer::from(4) * &big_a * &big_c);
if !delta.is_negative() {
return None; }
let (neg, sqf, k) = super::rt_algebraic::squarefree_signature(&delta)?;
debug_assert!(neg);
if let Some(sig) = &signature {
if *sig != sqf {
return None; }
} else {
signature = Some(sqf.clone());
}
let two_a = {
let big_a_r = Rational::new(big_a.to_i64()?, 1);
dom.add(&big_a_r, &big_a_r)
};
let c0 = dom.div(&Rational::new(-big_b.to_i64()?, 1), &two_a)?;
let c1 = dom.div(&Rational::new(k.to_i64()?, 1), &two_a)?;
quad_roots.push((c0, c1, sqf));
}
_ => return None,
}
}
if quad_roots.is_empty() {
return None;
}
let mut v_polys: Vec<KPoly> = Vec::with_capacity(rat_roots.len());
for c in &rat_roots {
let shifted = a1m.sub(&dd1m.mul_kelem(&KElem::from_rational(c, n)));
let v = d1m.gcd(&shifted).monic();
if v.degree()? == 0 {
return None;
}
v_polys.push(v);
}
let sqf_int = signature.clone()?;
let sqf = Rational::new(sqf_int.to_i64()?, 1);
let field = AlphaField {
sqf: sqf.clone(),
n,
};
let mut pairs: Vec<(Rational, Rational, KPoly, KPoly)> = Vec::new(); for (c0, c1, _) in &quad_roots {
let c_ka = KA {
re: KElem::from_rational(c0, n),
im: KElem::from_rational(c1, n),
};
let embed = |p: &KPoly| KAPoly {
coeffs: p.coeffs.iter().map(|c| field.embed(c.clone())).collect(),
};
let shifted = {
let a_ka = embed(a1m);
let d_ka = embed(dd1m);
let mut coeffs = a_ka.coeffs;
if coeffs.len() < d_ka.coeffs.len() {
coeffs.resize_with(d_ka.coeffs.len(), || field.zero());
}
for (i, dc) in d_ka.coeffs.iter().enumerate() {
coeffs[i] = field.sub(&coeffs[i], &field.mul(&c_ka, dc));
}
let mut p = KAPoly { coeffs };
p.trim();
p
};
let d_ka = embed(d1m);
let v = KAPoly::gcd(&field, &shifted, &d_ka)?;
if v.degree()? == 0 {
return None;
}
let u = KPoly {
top,
coeffs: v.coeffs.iter().map(|c| c.re.clone()).collect(),
n_vars: n,
};
let w_tilde = KPoly {
top,
coeffs: v.coeffs.iter().map(|c| c.im.clone()).collect(),
n_vars: n,
};
pairs.push((c0.clone(), c1.clone(), u, w_tilde));
}
let dt = &tower.gens[level - 1].dt;
let prefix = &tower.gens[..level - 1];
let sqf_k = KElem::from_rational(&sqf, n);
let mut prod = KPoly::one(top, n);
for v in &v_polys {
prod = prod.mul(v);
}
let mut pair_us: Vec<KElem> = Vec::with_capacity(pairs.len());
let mut pair_terms: Vec<KElem> = Vec::with_capacity(pairs.len());
for (p, c1, u, w) in &pairs {
let big_u = u.mul(u).add(&w.mul(w).mul_kelem(&sqf_k));
prod = prod.mul(&big_u);
let du = tower_diff_kpoly(u, prefix, dt);
let dw = tower_diff_kpoly(w, prefix, dt);
let d_big_u = tower_diff_kpoly(&big_u, prefix, dt);
let cross = dw.mul(u).sub(&w.mul(&du));
let cross_coeff = dom.mul(&Rational::new(2, 1), &dom.mul(c1, &sqf));
let num = d_big_u
.kelem()
.mul_rational(p)
.sub(&cross.kelem().mul_rational(&cross_coeff));
pair_terms.push(num.div(&big_u.kelem())?);
pair_us.push(big_u.kelem());
}
if !prod.sub(d1m).is_zero() {
return None;
}
let mut rem = a1m.kelem().div(&d1m.kelem())?;
let mut logs_out: Vec<(Rational, KElem)> = Vec::with_capacity(v_polys.len());
for (c, v) in rat_roots.iter().zip(v_polys.iter()) {
let dv = tower_diff_kpoly(v, prefix, dt);
let term = dv.kelem().div(&v.kelem())?.mul_rational(c);
rem = rem.sub(&term);
logs_out.push((c.clone(), v.kelem()));
}
for term in &pair_terms {
rem = rem.sub(term);
}
if rem.num.degree_in(top) > 0 || rem.den.degree_in(top) > 0 {
return None;
}
let gens = tower.gen_atoms();
let rad_atom = match super::rational::sqrt_positive_rational(ctx, &sqf)? {
super::rational::Sqrt::Rat(r) => crate::tower::convert::rational_const_to_atom(ctx, &r)?,
super::rational::Sqrt::Rad(a) => a,
};
let mut extras: Vec<Atom<'a>> = Vec::new();
for ((p, c1, u, w), big_u) in pairs.iter().zip(pair_us.iter()) {
let big_u_atom = kelem_atom(ctx, big_u, &gens)?;
if !dom.is_zero(p) {
let log = ctx.fun("log", &[big_u_atom]);
extras.push(ctx.mul(&[crate::tower::convert::rational_const_to_atom(ctx, p)?, log]));
}
let atan_coeff = dom.mul(&Rational::new(-2, 1), &dom.mul(c1, &sqf));
if !dom.is_zero(&atan_coeff) {
let w_atom = kelem_atom(ctx, &w.kelem(), &gens)?;
let u_atom = kelem_atom(ctx, &u.kelem(), &gens)?;
let arg = ctx.mul(&[rad_atom, w_atom, ctx.pow(u_atom, ctx.num(-1))]);
let atan = ctx.fun("atan", &[arg]);
extras.push(ctx.mul(&[
crate::tower::convert::rational_const_to_atom(ctx, &atan_coeff)?,
atan,
]));
}
}
Some((logs_out, rem, extras))
}
fn kelem_atom<'a>(ctx: &'a AtomArena<'a>, e: &KElem, gens: &[Atom<'a>]) -> Option<Atom<'a>> {
crate::tower::convert::rational_to_atom(
ctx,
&crate::tower::convert::GeneratorField::from_num_den(e.num.clone(), e.den.clone()),
gens,
)
}
#[cfg(test)]
mod tests {
use ocas_atom::{Atom, AtomArena, Symbol};
use ocas_core::arena::Arena;
use super::*;
use crate::tower::build::build_tower;
fn simple_remainder<'a>(
ctx: &'a AtomArena<'a>,
expr: Atom<'a>,
) -> (Tower<'a>, usize, KPoly, KPoly) {
let tower = build_tower(ctx, expr, Symbol::new("x")).expect("tower");
let level = tower.gens.len();
let rf = crate::tower::convert::atom_to_rational_extended(
tower.expr,
&tower.gen_atoms(),
tower.n_vars(),
)
.expect("field element");
let f = crate::tower::elem::KRat::new(
KPoly::from_sparse(&rf.numerator, level),
KPoly::from_sparse(&rf.denominator, level),
);
let (_p, r) = f.num.div_rem(&f.den);
let (_g, a1, d1) =
crate::integral::risch::hermite_tower(&tower, level, &r, &f.den).expect("hermite");
(tower, level, a1, d1)
}
#[test]
fn rt_exp_level_single_root() {
let arena = Arena::new();
let ctx = AtomArena::new(&arena);
let x = ctx.var("x");
let f = ctx.pow(ctx.add(&[ctx.num(1), ctx.fun("exp", &[x])]), ctx.num(-1));
let (tower, level, a1, d1) = simple_remainder(&ctx, f);
let (logs, rem, _extras) = rothstein_trager(&ctx, &tower, level, &a1, &d1).expect("RT");
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].0, Rational::new(-1, 1));
assert!(rem.num.degree_in(level) == 0 && rem.den.degree_in(level) == 0);
}
#[test]
fn rt_exp_level_double_root_once() {
let arena = Arena::new();
let ctx = AtomArena::new(&arena);
let x = ctx.var("x");
let two_x = ctx.mul(&[ctx.num(2), x]);
let f = ctx.pow(
ctx.add(&[ctx.fun("exp", &[two_x]), ctx.num(-1)]),
ctx.num(-1),
);
let (tower, level, a1, d1) = simple_remainder(&ctx, f);
let (logs, rem, _extras) = rothstein_trager(&ctx, &tower, level, &a1, &d1).expect("RT");
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].0, Rational::new(1, 2));
assert!(rem.num.degree_in(level) == 0 && rem.den.degree_in(level) == 0);
}
#[test]
fn rt_log_level_two_roots() {
let arena = Arena::new();
let ctx = AtomArena::new(&arena);
let x = ctx.var("x");
let log_x = ctx.fun("log", &[x]);
let den = ctx.mul(&[x, log_x, ctx.add(&[log_x, ctx.num(1)])]);
let f = ctx.pow(den, ctx.num(-1));
let (tower, level, a1, d1) = simple_remainder(&ctx, f);
let (logs, rem, _extras) = rothstein_trager(&ctx, &tower, level, &a1, &d1).expect("RT");
assert_eq!(logs.len(), 2);
let cs: Vec<Rational> = logs.iter().map(|(c, _)| c.clone()).collect();
assert!(cs.contains(&Rational::new(-1, 1)) && cs.contains(&Rational::new(1, 1)));
assert!(rem.is_zero());
}
#[test]
fn rt_declines_a_non_constant_root() {
let arena = Arena::new();
let ctx = AtomArena::new(&arena);
let x = ctx.var("x");
let f = ctx.pow(ctx.add(&[ctx.fun("exp", &[x]), x]), ctx.num(-1));
let (tower, level, a1, d1) = simple_remainder(&ctx, f);
assert!(rothstein_trager(&ctx, &tower, level, &a1, &d1).is_none());
}
#[test]
fn rt_exp_level_complex_roots_realify() {
let arena = Arena::new();
let ctx = AtomArena::new(&arena);
let x = ctx.var("x");
let t = ctx.fun("exp", &[x]);
let f = ctx.mul(&[
t,
ctx.pow(ctx.add(&[ctx.num(1), ctx.pow(t, ctx.num(2))]), ctx.num(-1)),
]);
let (tower, level, a1, d1) = simple_remainder(&ctx, f);
let (logs, rem, extras) =
rothstein_trager(&ctx, &tower, level, &a1, &d1).expect("RT with complex roots");
assert!(logs.is_empty(), "no rational-root logs: {logs:?}");
assert!(rem.is_zero(), "the remainder must vanish at this level");
assert_eq!(extras.len(), 1, "one realified atan term: {extras:?}");
let s = extras[0].to_string();
assert!(s.contains("atan"), "expected atan: {s}");
}
}