use super::hyperterm::{ratk_to_expr, ratuni_to_expr, ProperTerm};
use super::qfield::{
clear_denominators, rn_div, rn_inv, rn_is_zero, rn_mul, rn_one, rn_poly, rn_sub, rn_zero,
PolyK, RatK, Rn,
};
use super::HolonomicError;
use crate::deriv::log::{DerivationLog, DerivedExpr, RewriteStep};
use crate::kernel::{ExprId, ExprPool};
use crate::matrix::normal_form::RatUniPoly;
#[derive(Debug, Clone, Copy)]
pub struct ZeilbergerOpts {
pub max_order: usize,
pub max_degree: usize,
}
impl Default for ZeilbergerOpts {
fn default() -> Self {
ZeilbergerOpts {
max_order: 4,
max_degree: 16,
}
}
}
#[derive(Debug, Clone)]
pub struct ZeilbergerResult {
pub order: usize,
pub coeffs: Vec<ExprId>,
pub certificate: ExprId,
}
pub fn boundary_term(result: &ZeilbergerResult, term: ExprId, pool: &ExprPool) -> ExprId {
crate::simplify::simplify(pool.mul(vec![result.certificate, term]), pool).value
}
pub const fn boundary_side_condition() -> &'static str {
"the recurrence Σ_i a_i(n)·S(n+i) = 0 for S(n) = Σ_k F(n,k) additionally requires \
G(n, k_hi+1) = G(n, k_lo) over the summation range, where G(n,k) = R(n,k)·F(n,k); \
Zeilberger verifies the telescoping identity in k, not this boundary condition. \
It holds for the usual natural boundary (F vanishing outside 0 <= k <= n) and fails \
for e.g. F = C(n,k)/(k+1), where G(n,0) = -1 makes the recurrence inhomogeneous"
}
fn k_mono(j: usize) -> PolyK {
let mut coeffs = vec![rn_zero(); j + 1];
coeffs[j] = rn_one();
PolyK::from_coeffs(coeffs)
}
fn gosper_normal_form_qn(mut p: PolyK, mut q: PolyK) -> Option<(PolyK, PolyK, PolyK)> {
if p.is_zero() {
return Some((PolyK::zero(), PolyK::one(), PolyK::one()));
}
if q.is_zero() {
return None;
}
let lc_p = p.leading_coeff();
let lc_q = q.leading_coeff();
let z_scale = rn_div(&lc_p, &lc_q)?;
p = p.scale(&rn_inv(&lc_p)?);
q = q.scale(&rn_inv(&lc_q)?);
let mut a = p;
let mut b = q;
let mut c = PolyK::one();
let bound = (a.degree().max(0) + b.degree().max(0)).max(1) as usize + 32;
loop {
let mut found = false;
for i in 0..=bound {
let bshift = b.shift_k(i as i64);
let d = PolyK::gcd(&a, &bshift);
if d.is_zero() || d.degree() == 0 {
continue;
}
let Some(an) = PolyK::exact_div(&a, &d) else {
continue;
};
let dsmi = d.shift_k(-(i as i64));
let Some(bn) = PolyK::exact_div(&b, &dsmi) else {
continue;
};
a = an;
b = bn;
let mut prod = PolyK::one();
for j in 1..=i {
prod = prod.mul(&d.shift_k(-(j as i64)));
}
c = c.mul(&prod);
found = true;
break;
}
if !found {
break;
}
}
a = a.scale(&z_scale);
Some((a, b, c))
}
pub(super) fn field_gaussian_solve(mut mat: Vec<Vec<Rn>>, mut rhs: Vec<Rn>) -> Option<Vec<Rn>> {
let nrows = mat.len();
if nrows == 0 {
return Some(vec![]);
}
let ncols = mat[0].len();
let mut row = 0;
for col in 0..ncols {
if row >= nrows {
break;
}
let pr = (row..nrows).find(|&r| !rn_is_zero(&mat[r][col]));
let Some(pr) = pr else {
continue;
};
mat.swap(row, pr);
rhs.swap(row, pr);
let inv = rn_inv(&mat[row][col])?;
for entry in mat[row].iter_mut().skip(col) {
*entry = rn_mul(entry, &inv);
}
rhs[row] = rn_mul(&rhs[row], &inv);
let pivot_row = mat[row].clone();
let pivot_rhs = rhs[row].clone();
for r in 0..nrows {
if r == row {
continue;
}
let v = mat[r][col].clone();
if rn_is_zero(&v) {
continue;
}
for (entry, pivot) in mat[r].iter_mut().zip(pivot_row.iter()).skip(col) {
*entry = rn_sub(entry, &rn_mul(pivot, &v));
}
rhs[r] = rn_sub(&rhs[r], &rn_mul(&pivot_rhs, &v));
}
row += 1;
}
for (r, mrow) in mat.iter().enumerate() {
let all_zero = mrow.iter().all(rn_is_zero);
if all_zero && !rn_is_zero(&rhs[r]) {
return None;
}
}
let mut sol = vec![rn_zero(); ncols];
for r in (0..nrows).rev() {
let first = mat[r].iter().position(|e| !rn_is_zero(e));
if let Some(j) = first {
let mut sum = rhs[r].clone();
for cidx in (j + 1)..ncols {
sum = rn_sub(&sum, &rn_mul(&mat[r][cidx], &sol[cidx]));
}
sol[j] = rn_div(&sum, &mat[r][j])?;
}
}
Some(sol)
}
fn try_solve(
aa: &PolyK,
b_eq: &PolyK,
c_ci: &[PolyK],
order: usize,
d: usize,
) -> Option<(Vec<Rn>, Vec<Rn>)> {
let mut bx: Vec<PolyK> = Vec::with_capacity(d + 1);
for j in 0..=d {
let kp1j = k_mono(j).shift_k(1);
let kj = k_mono(j);
let term_a = aa.mul(&kp1j);
let term_b = b_eq.mul(&kj);
bx.push(term_a.sub(&term_b));
}
let mut max_deg = 0i32;
for p in &bx {
max_deg = max_deg.max(p.degree());
}
for p in c_ci {
max_deg = max_deg.max(p.degree());
}
if max_deg < 0 {
max_deg = 0;
}
let n_eq = (max_deg as usize) + 1;
let n_var = (d + 1) + order;
let mut mat = vec![vec![rn_zero(); n_var]; n_eq];
let mut rhs = vec![rn_zero(); n_eq];
for (m, row) in mat.iter_mut().enumerate() {
for (j, bxj) in bx.iter().enumerate() {
row[j] = bxj.coeff(m);
}
for i in 0..order {
row[(d + 1) + i] = super::qfield::rn_neg(&c_ci[i].coeff(m));
}
rhs[m] = c_ci[order].coeff(m);
}
let sol = field_gaussian_solve(mat, rhs)?;
let x_coeffs = sol[..=d].to_vec();
let lam_below = sol[(d + 1)..].to_vec();
Some((x_coeffs, lam_below))
}
struct OrderState {
c: Vec<RatK>,
dden: PolyK,
aa: PolyK,
b_eq: PolyK,
cc: PolyK,
c_ci: Vec<PolyK>,
}
fn order_state(
f: &ProperTerm,
p: &RatK,
order: usize,
) -> Result<Option<OrderState>, HolonomicError> {
let c: Vec<RatK> = (0..=order as i64)
.map(|i| f.ratio_n(i))
.collect::<Result<_, _>>()?;
let mut dden = PolyK::one();
for ci in &c {
dden = PolyK::lcm(&dden, &ci.den);
}
if dden.is_zero() {
return Ok(None);
}
let ci_polys: Option<Vec<PolyK>> = c
.iter()
.map(|ci| PolyK::exact_div(&dden.mul(&ci.num), &ci.den))
.collect();
let Some(ci_polys) = ci_polys else {
return Ok(None);
};
let rho_num = p.num.mul(&dden);
let rho_den = p.den.mul(&dden.shift_k(1));
let Some((aa, bb, cc)) = gosper_normal_form_qn(rho_num, rho_den) else {
return Ok(None);
};
let b_eq = bb.shift_k(-1);
let c_ci: Vec<PolyK> = ci_polys.iter().map(|q| cc.mul(q)).collect();
Ok(Some(OrderState {
c,
dden,
aa,
b_eq,
cc,
c_ci,
}))
}
const ORDER_COST_IN_DEGREE_STEPS: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OrderSearch {
#[default]
CostOrdered,
MinimalOrder,
}
#[derive(Debug, Clone)]
pub struct ZeilbergerSearchReport {
pub result: ZeilbergerResult,
pub order_is_minimal: bool,
pub probes: usize,
}
fn search_plan(max_order: usize, max_degree: usize, search: OrderSearch) -> Vec<(usize, usize)> {
let mut plan = Vec::with_capacity(max_order * (max_degree + 1));
match search {
OrderSearch::MinimalOrder => {
for order in 1..=max_order {
for d in 0..=max_degree {
plan.push((order, d));
}
}
}
OrderSearch::CostOrdered => {
let max_budget = ORDER_COST_IN_DEGREE_STEPS * (max_order - 1) + max_degree;
for budget in 0..=max_budget {
for order in 1..=max_order {
let spent = ORDER_COST_IN_DEGREE_STEPS * (order - 1);
if let Some(d) = budget.checked_sub(spent) {
if d <= max_degree {
plan.push((order, d));
}
}
}
}
}
}
plan
}
pub fn zeilberger(
term: ExprId,
n: ExprId,
k: ExprId,
pool: &ExprPool,
opts: &ZeilbergerOpts,
) -> Result<DerivedExpr<ZeilbergerResult>, HolonomicError> {
zeilberger_search(term, n, k, pool, opts, OrderSearch::CostOrdered)
.map(|d| d.map(|report| report.result))
}
pub fn zeilberger_search(
term: ExprId,
n: ExprId,
k: ExprId,
pool: &ExprPool,
opts: &ZeilbergerOpts,
search: OrderSearch,
) -> Result<DerivedExpr<ZeilbergerSearchReport>, HolonomicError> {
if n == k {
return Err(HolonomicError::InvalidInput(
"the outer index n and the summation index k must be distinct symbols".into(),
));
}
if opts.max_order == 0 || opts.max_degree == 0 {
return Err(HolonomicError::InvalidInput(
"max_order and max_degree must both be at least 1".into(),
));
}
let f = ProperTerm::parse(term, n, k, pool)?;
let p = f.ratio_k()?;
let mut states: Vec<Option<OrderState>> = Vec::with_capacity(opts.max_order);
let mut degrees_failed = vec![0usize; opts.max_order];
for (order, d) in search_plan(opts.max_order, opts.max_degree, search) {
degrees_failed[order - 1] += 1;
while states.len() < order {
states.push(order_state(&f, &p, states.len() + 1)?);
}
let Some(state) = &states[order - 1] else {
continue;
};
let Some((x_coeffs, lam_below)) = try_solve(&state.aa, &state.b_eq, &state.c_ci, order, d)
else {
continue;
};
let mut lam_full = lam_below;
lam_full.push(rn_one());
let x_poly = PolyK::from_coeffs(x_coeffs);
let r_pre = RatK {
num: state.b_eq.mul(&x_poly),
den: state.cc.mul(&state.dden),
}
.normalize();
let a_int: Vec<RatUniPoly> = clear_denominators(&lam_full);
if a_int.iter().all(|p| p.is_zero()) {
continue;
}
let scale = rn_poly(a_int[order].clone());
if rn_is_zero(&scale) {
continue;
}
let r_final = RatK {
num: r_pre.num.scale(&scale),
den: r_pre.den.clone(),
}
.normalize();
let mut lhs = RatK::zero();
for (i, ci) in state.c.iter().enumerate() {
let ai = RatK::from_rn(rn_poly(a_int[i].clone()));
lhs = lhs.add(&ai.mul(ci));
}
let rhs_check = r_final.shift_k(1).mul(&p).sub(&r_final);
if !lhs.sub(&rhs_check).is_zero() {
continue;
}
let coeffs_expr: Vec<ExprId> = a_int.iter().map(|p| ratuni_to_expr(pool, n, p)).collect();
let certificate_expr = ratk_to_expr(pool, n, k, &r_final);
let mut log = DerivationLog::new();
log.push(RewriteStep::simple(
"zeilberger_certificate",
term,
certificate_expr,
));
let order_is_minimal = (1..order).all(|j| degrees_failed[j - 1] == opts.max_degree + 1);
return Ok(DerivedExpr::with_log(
ZeilbergerSearchReport {
result: ZeilbergerResult {
order,
coeffs: coeffs_expr,
certificate: certificate_expr,
},
order_is_minimal,
probes: degrees_failed.iter().sum(),
},
log,
));
}
Err(HolonomicError::SearchExhausted(format!(
"no verified P-recursive relation of order <= {} with certificate degree <= {} \
in k was found for {}",
opts.max_order,
opts.max_degree,
pool.display(term)
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kernel::Domain;
fn nk(pool: &ExprPool) -> (ExprId, ExprId) {
(
pool.symbol("n", Domain::Real),
pool.symbol("k", Domain::Real),
)
}
fn binom(pool: &ExprPool, top: ExprId, bot: ExprId) -> ExprId {
let g1 = pool.func("gamma", vec![pool.add(vec![top, pool.integer(1_i32)])]);
let g2 = pool.func("gamma", vec![pool.add(vec![bot, pool.integer(1_i32)])]);
let g3 = pool.func(
"gamma",
vec![pool.add(vec![
top,
pool.mul(vec![bot, pool.integer(-1_i32)]),
pool.integer(1_i32),
])],
);
pool.mul(vec![
g1,
pool.pow(g2, pool.integer(-1_i32)),
pool.pow(g3, pool.integer(-1_i32)),
])
}
#[test]
fn binomial_row_sum_order_one() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let f = binom(&pool, n, k);
let opts = ZeilbergerOpts::default();
let result = zeilberger(f, n, k, &pool, &opts).expect("Zeilberger must find a certificate");
let r = &result.value;
assert_eq!(r.order, 1, "expected order-1 recurrence for Σ_k C(n,k)");
assert_eq!(r.coeffs.len(), 2);
for ni in [3.0_f64, 7.0, 11.5] {
let env = std::collections::HashMap::from([(n, ni)]);
let a0 = crate::eval_f64(r.coeffs[0], &pool, &env).expect("a_0(n) evaluates");
let a1 = crate::eval_f64(r.coeffs[1], &pool, &env).expect("a_1(n) evaluates");
assert!(a1.abs() > 1e-12, "leading coefficient must not vanish");
assert!(
(a0 / a1 + 2.0).abs() < 1e-9,
"expected a_0/a_1 = -2 (S(n+1) = 2·S(n)), got {}",
a0 / a1
);
}
}
#[test]
fn boundary_term_is_available_and_nonzero_where_the_hypothesis_fails() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let kp1 = pool.add(vec![k, pool.integer(1_i32)]);
let f = pool.mul(vec![
binom(&pool, n, k),
pool.pow(kp1, pool.integer(-1_i32)),
]);
let opts = ZeilbergerOpts::default();
let result = zeilberger(f, n, k, &pool, &opts).expect("certificate");
let g = boundary_term(&result.value, f, &pool);
let mut m = std::collections::HashMap::new();
m.insert(k, pool.integer(0_i32));
let g_at_0 = crate::simplify::simplify(crate::kernel::subs(g, &m, &pool), &pool).value;
for ni in [2.0_f64, 5.0, 9.0] {
let env = std::collections::HashMap::from([(n, ni)]);
let v = crate::eval_f64(g_at_0, &pool, &env).expect("G(n,0) evaluates");
assert!(
(v + 1.0).abs() < 1e-9,
"G({ni}, 0) should be -1, got {v} — the boundary difference does not vanish"
);
}
assert!(boundary_side_condition().contains("G(n, k_hi+1) = G(n, k_lo)"));
}
fn assert_annihilates(coeffs: &[f64], s: impl Fn(u64) -> f64, ni: u64, what: &str) {
let total: f64 = coeffs
.iter()
.enumerate()
.map(|(i, ai)| ai * s(ni + i as u64))
.sum();
let scale = coeffs
.iter()
.enumerate()
.map(|(i, ai)| (ai * s(ni + i as u64)).abs())
.fold(1.0_f64, f64::max);
assert!(
total.abs() < 1e-6 * scale,
"{what}: recurrence must annihilate S(n) at n = {ni}, got {total}"
);
}
fn coeffs_at(r: &ZeilbergerResult, n: ExprId, pool: &ExprPool, ni: u64) -> Vec<f64> {
let env = std::collections::HashMap::from([(n, ni as f64)]);
r.coeffs
.iter()
.map(|&e| crate::eval_f64(e, pool, &env).expect("a_i(n) evaluates"))
.collect()
}
fn binom_f64(m: u64, j: u64) -> f64 {
(1..=j).fold(1.0_f64, |acc, t| acc * (m - t + 1) as f64 / t as f64)
}
#[test]
fn dixon_order_two_is_reachable_at_default_bounds() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let c = binom(&pool, n, k);
let sign = pool.pow(pool.integer(-1_i32), k);
let f = pool.mul(vec![sign, c, c, c]);
let start = std::time::Instant::now();
let result = zeilberger(f, n, k, &pool, &ZeilbergerOpts::default())
.expect("Dixon must be decided at the default bounds");
println!(
"dixon: order {} in {:?}",
result.value.order,
start.elapsed()
);
let r = &result.value;
assert_eq!(
r.order, 2,
"Σ_k (−1)^k C(n,k)³ satisfies an order-2 relation"
);
assert_eq!(r.coeffs.len(), 3);
let s = |m: u64| -> f64 {
(0..=m)
.map(|j| {
let b = binom_f64(m, j);
if j % 2 == 0 {
b * b * b
} else {
-(b * b * b)
}
})
.sum()
};
for ni in 2_u64..8 {
assert_annihilates(&coeffs_at(r, n, &pool, ni), s, ni, "dixon");
}
}
#[test]
fn franel_order_two_is_reachable_at_default_bounds() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let c = binom(&pool, n, k);
let f = pool.mul(vec![c, c, c]);
let start = std::time::Instant::now();
let result = zeilberger(f, n, k, &pool, &ZeilbergerOpts::default())
.expect("Franel must be decided at the default bounds");
let elapsed = start.elapsed();
println!("franel: order {} in {:?}", result.value.order, elapsed);
if !cfg!(debug_assertions) {
assert!(
elapsed < std::time::Duration::from_secs(10),
"Franel took {elapsed:?} at the default bounds — the exact Q(n)(k) \
post-processing has regressed to the coefficient blowup it used to have"
);
}
let r = &result.value;
assert_eq!(r.order, 2, "Σ_k C(n,k)³ satisfies an order-2 recurrence");
assert_eq!(r.coeffs.len(), 3);
let s = |m: u64| -> f64 {
(0..=m)
.map(|j| {
let b = binom_f64(m, j);
b * b * b
})
.sum()
};
for ni in 2_u64..7 {
assert_annihilates(&coeffs_at(r, n, &pool, ni), s, ni, "franel");
}
}
#[test]
fn search_plan_is_a_permutation_of_the_bounded_grid() {
for search in [OrderSearch::CostOrdered, OrderSearch::MinimalOrder] {
for max_order in 1..6usize {
for max_degree in 1..20usize {
assert_plan_is_a_permutation(max_order, max_degree, search);
}
}
}
}
fn assert_plan_is_a_permutation(max_order: usize, max_degree: usize, search: OrderSearch) {
let plan = search_plan(max_order, max_degree, search);
let mut sorted = plan.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
plan.len(),
"no pair may be probed twice ({max_order}, {max_degree}, {search:?})"
);
assert_eq!(
plan.len(),
max_order * (max_degree + 1),
"every pair in the bounds must be probed ({max_order}, {max_degree}, {search:?})"
);
assert!(
plan.iter()
.all(|&(o, d)| (1..=max_order).contains(&o) && d <= max_degree),
"the plan must stay inside the caller's bounds ({search:?})"
);
}
#[test]
fn search_plan_visits_cheap_probes_first() {
let plan = search_plan(3, 8, OrderSearch::CostOrdered);
let cost = |&(o, d): &(usize, usize)| ORDER_COST_IN_DEGREE_STEPS * (o - 1) + d;
assert!(
plan.windows(2).all(|w| cost(&w[0]) <= cost(&w[1])),
"probes must be visited in nondecreasing estimated cost: {plan:?}"
);
assert_eq!(plan[0], (1, 0), "the cheapest probe is order 1, degree 0");
assert_eq!(&plan[..4], &[(1, 0), (1, 1), (1, 2), (1, 3)]);
assert_eq!(plan[4], (2, 0));
}
#[test]
fn minimal_order_plan_exhausts_each_order_before_the_next() {
let plan = search_plan(3, 8, OrderSearch::MinimalOrder);
assert!(
plan.windows(2)
.all(|w| w[0].0 < w[1].0 || (w[0].0 == w[1].0 && w[0].1 < w[1].1)),
"order-major, degree-ascending within an order: {plan:?}"
);
assert_eq!(&plan[..3], &[(1, 0), (1, 1), (1, 2)]);
assert_eq!(plan[9], (2, 0));
}
#[test]
fn order_one_is_minimal_under_the_default_plan() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let f = binom(&pool, n, k);
let report = zeilberger_search(
f,
n,
k,
&pool,
&ZeilbergerOpts::default(),
OrderSearch::CostOrdered,
)
.expect("certificate");
assert_eq!(report.value.result.order, 1);
assert!(
report.value.order_is_minimal,
"order 1 is minimal by definition"
);
assert_eq!(report.value.probes, 1, "decided by the very first probe");
}
#[test]
fn default_plan_does_not_claim_minimality_it_has_not_established() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let c = binom(&pool, n, k);
let sign = pool.pow(pool.integer(-1_i32), k);
let f = pool.mul(vec![sign, c, c, c]);
let opts = ZeilbergerOpts::default();
let fast = zeilberger_search(f, n, k, &pool, &opts, OrderSearch::CostOrdered)
.expect("Dixon is decided at the default bounds");
assert_eq!(fast.value.result.order, 2);
assert!(
!fast.value.order_is_minimal,
"the cost-ordered plan skipped order-1 degrees 4..=16, so it cannot \
claim order 2 is minimal — and must not"
);
assert!(
fast.value.probes <= opts.max_degree,
"the whole point of the cost-ordered plan is that it decides Dixon \
long before the order-1 sweep is done, got {} probes",
fast.value.probes
);
}
#[test]
fn minimal_mode_establishes_minimality() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let c = binom(&pool, n, k);
let sign = pool.pow(pool.integer(-1_i32), k);
let f = pool.mul(vec![sign, c, c, c]);
let opts = ZeilbergerOpts {
max_order: 4,
max_degree: 4,
};
let start = std::time::Instant::now();
let sharp = zeilberger_search(f, n, k, &pool, &opts, OrderSearch::MinimalOrder)
.expect("Dixon is decided order-ascending at max_degree = 4");
println!(
"dixon (minimal): order {} in {:?} after {} probes",
sharp.value.result.order,
start.elapsed(),
sharp.value.probes
);
assert_eq!(sharp.value.result.order, 2);
assert!(
sharp.value.order_is_minimal,
"every order-1 degree in bounds was refused, so order 2 is minimal"
);
assert!(
sharp.value.probes > opts.max_degree,
"minimality is bought with the full low-order sweep, got {} probes",
sharp.value.probes
);
assert_eq!(sharp.value.result.coeffs.len(), 3);
}
#[test]
fn refuses_non_hypergeometric_input() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let bad = pool.func("sin", vec![pool.mul(vec![n, k])]);
let opts = ZeilbergerOpts::default();
let err = zeilberger(bad, n, k, &pool, &opts).expect_err("sin(nk) is not hypergeometric");
assert!(matches!(err, HolonomicError::NotProperHypergeometric(_)));
assert_eq!(crate::errors::AlkahestError::code(&err), "E-HOLO-001");
}
#[test]
fn refuses_coincident_indices() {
let pool = ExprPool::new();
let n = pool.symbol("n", Domain::Real);
let opts = ZeilbergerOpts::default();
let err = zeilberger(n, n, n, &pool, &opts).expect_err("n == k must be refused");
assert!(matches!(err, HolonomicError::InvalidInput(_)));
}
#[test]
fn certificate_reverifies_exactly() {
let pool = ExprPool::new();
let (n, k) = nk(&pool);
let f = binom(&pool, n, k);
let opts = ZeilbergerOpts::default();
let result = zeilberger(f, n, k, &pool, &opts).expect("certificate");
let r = &result.value;
let term = ProperTerm::parse(f, n, k, &pool).expect("parse");
let c: Vec<RatK> = (0..=r.order as i64)
.map(|i| term.ratio_n(i).expect("ratio_n"))
.collect();
let p = term.ratio_k().expect("ratio_k");
let a: Vec<RatK> = r
.coeffs
.iter()
.map(|&e| {
let ratk = super::super::hyperterm::as_ratk(e, n, k, &pool, 0)
.expect("coeff must be a function of n alone");
assert_eq!(ratk.num.degree().max(0), 0, "coeff must not depend on k");
assert_eq!(ratk.den.degree(), 0, "coeff must not depend on k");
ratk
})
.collect();
let r_ratk = super::super::hyperterm::as_ratk(r.certificate, n, k, &pool, 0)
.expect("certificate must parse back into Q(n)(k)");
let mut lhs = RatK::zero();
for (i, ci) in c.iter().enumerate() {
lhs = lhs.add(&a[i].mul(ci));
}
let rhs = r_ratk.shift_k(1).mul(&p).sub(&r_ratk);
assert!(
lhs.sub(&rhs).is_zero(),
"returned certificate must satisfy the exact identity"
);
}
}