use super::cyclotomic::{CycloElem, CycloField, MAX_CYCLOTOMIC_ORDER};
use super::field::polyx_at_qn;
use super::{QBoundaryStatus, QCertificate, QHolonomicError};
use crate::holonomic::qfield::{rn_add, rn_is_zero, rn_mul, rn_zero, Rn};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QRootOfUnityStatus {
Specializes,
Obstructed {
reason: String,
},
Unknown {
reason: String,
},
}
impl QRootOfUnityStatus {
pub fn tag(&self) -> &'static str {
match self {
QRootOfUnityStatus::Specializes => "specializes",
QRootOfUnityStatus::Obstructed { .. } => "obstructed",
QRootOfUnityStatus::Unknown { .. } => "unknown",
}
}
pub fn reason(&self) -> &str {
match self {
QRootOfUnityStatus::Specializes => "",
QRootOfUnityStatus::Obstructed { reason } | QRootOfUnityStatus::Unknown { reason } => {
reason
}
}
}
}
#[derive(Debug, Clone)]
pub struct QRootOfUnitySpecialization {
pub d: u32,
pub n0: i64,
pub status: QRootOfUnityStatus,
pub field: CycloField,
pub window: Option<(i64, i64)>,
pub sums: Vec<CycloElem>,
pub sum_valuations: Vec<Option<i64>>,
pub coeffs: Vec<CycloElem>,
pub effective_support: Vec<i64>,
pub generic_support_size: usize,
pub termwise_regular: bool,
}
impl QRootOfUnitySpecialization {
pub fn specializes(&self) -> bool {
matches!(self.status, QRootOfUnityStatus::Specializes)
}
pub fn is_vacuous(&self) -> bool {
self.specializes() && !self.coeffs.is_empty() && self.coeffs.iter().all(CycloElem::is_zero)
}
pub fn leading_coefficient_survives(&self) -> bool {
self.specializes() && self.coeffs.last().is_some_and(|c| !c.is_zero())
}
pub fn is_termwise_regular(&self) -> bool {
self.termwise_regular
}
pub fn support_shrinks(&self) -> bool {
self.effective_support.len() < self.generic_support_size
}
pub fn side_conditions(&self) -> Vec<String> {
let d = self.d;
let n0 = self.n0;
match &self.status {
QRootOfUnityStatus::Specializes => {
let mut out = vec![format!(
"every coefficient a_i(q**{n0}) and every sum S({n0}+i) was proved to have \
non-negative Phi_{d}-adic valuation, so the specialisation map at a primitive \
{d}-th root of unity is defined on all of them and carries the proved Q(q) \
recurrence to sum_i a_i(zeta^{n0})*S_zeta({n0}+i) = 0 in Q(zeta_{d}); the \
specialised identity was re-checked in exact cyclotomic arithmetic"
)];
if self.is_vacuous() {
out.push(format!(
"the specialised recurrence is VACUOUS: every a_i(zeta^{n0}) vanishes at a \
primitive {d}-th root of unity, so the statement is 0 = 0 and constrains \
nothing. The specialised sum values are still correct"
));
} else if !self.leading_coefficient_survives() {
out.push(format!(
"the leading coefficient a_J(zeta^{n0}) vanishes at a primitive {d}-th \
root of unity, so the specialised recurrence does not determine the last \
value from the earlier ones"
));
}
if !self.termwise_regular {
out.push(format!(
"at least one individual summand F(m, k) inside the window has a pole at a \
primitive {d}-th root of unity: the reported S_zeta values are the images \
of the exact Q(q) sums, and are NOT the sums of the specialised summands"
));
}
if self.support_shrinks() {
out.push(format!(
"the support shrank under specialisation: {} of the {} terms that are \
non-zero in Q(q) vanish at a primitive {d}-th root of unity (the q-Lucas \
phenomenon). The sum is still over the same window; the vanishing terms \
simply contribute nothing",
self.generic_support_size - self.effective_support.len(),
self.generic_support_size
));
}
out.push(
"zeta_d is any primitive d-th root of unity: the statement is an identity in \
Q(zeta_d) = Q[q]/(Phi_d(q)), so it holds for every primitive d-th root at \
once, not for one chosen embedding"
.to_string(),
);
out
}
QRootOfUnityStatus::Obstructed { reason } => vec![
format!(
"the specialisation at a primitive {d}-th root of unity is obstructed: \
{reason}. Nothing about the specialised sum or recurrence follows; the proved \
statement remains the Q(q) one with q transcendental"
),
"an obstruction here is a proof that this route is blocked, not a proof that the \
specialised identity is false"
.to_string(),
],
QRootOfUnityStatus::Unknown { reason } => vec![format!(
"the specialisation was not decided: {reason}. Nothing follows about q = zeta_{d}"
)],
}
}
}
pub fn q_specialize_at_root_of_unity(
cert: &QCertificate,
d: u32,
n0: i64,
) -> Result<QRootOfUnitySpecialization, QHolonomicError> {
let field = CycloField::new(d).ok_or_else(|| {
QHolonomicError::InvalidInput(format!(
"the order of the root of unity must be between 1 and {MAX_CYCLOTOMIC_ORDER}, got {d}"
))
})?;
let n_min = match &cert.boundary {
QBoundaryStatus::Vanishes { n_min, .. } => *n_min,
QBoundaryStatus::Unknown { reason } => {
return Ok(unknown(
field,
n0,
format!(
"the generic boundary verdict is already \"unknown\", so there is no proved \
Q(q) recurrence for the sum to specialise: {reason}"
),
));
}
};
if n0 < n_min {
return Err(QHolonomicError::InvalidInput(format!(
"the boundary verdict covers n >= {n_min}, so it cannot be specialised at n = {n0}"
)));
}
let order = cert.report.result.order;
let window = cert.term.window_at(n0, n_min)?;
let mut generic_sums: Vec<Rn> = Vec::with_capacity(order + 1);
for i in 0..=order as i64 {
generic_sums.push(cert.term.sum_at(n0 + i, n_min)?);
}
let generic_coeffs: Vec<Rn> = cert
.report
.result
.coeffs_x
.iter()
.map(|a| polyx_at_qn(a, n0))
.collect();
if generic_coeffs.len() != generic_sums.len() {
return Err(QHolonomicError::CertificateVerificationFailed(format!(
"the certificate reports order {order} but carries {} coefficients",
generic_coeffs.len()
)));
}
let mut acc = rn_zero();
for (c, s) in generic_coeffs.iter().zip(generic_sums.iter()) {
acc = rn_add(&acc, &rn_mul(c, s));
}
if !rn_is_zero(&acc) {
return Err(QHolonomicError::CertificateVerificationFailed(format!(
"the proved Q(q) recurrence does not annihilate the exact q-series sums at n = {n0}; \
refusing to specialise a premise that does not hold"
)));
}
let sum_valuations: Vec<Option<i64>> =
generic_sums.iter().map(|s| field.valuation(s)).collect();
let mut coeffs = Vec::with_capacity(generic_coeffs.len());
for (i, c) in generic_coeffs.iter().enumerate() {
match field.specialize(c) {
Some(v) => coeffs.push(v),
None => {
let v = field.valuation(c).unwrap_or(0);
return Ok(obstructed(
field,
n0,
d,
window,
sum_valuations,
format!(
"the recurrence coefficient a_{i}(q**{n0}) has Phi_{d}-adic valuation {v} \
(< 0), i.e. a pole at a primitive {d}-th root of unity"
),
));
}
}
}
let mut sums = Vec::with_capacity(generic_sums.len());
for (i, s) in generic_sums.iter().enumerate() {
match field.specialize(s) {
Some(v) => sums.push(v),
None => {
let v = field.valuation(s).unwrap_or(0);
let m = n0 + i as i64;
return Ok(obstructed(
field,
n0,
d,
window,
sum_valuations,
format!(
"the sum S({m}) has Phi_{d}-adic valuation {v} (< 0), i.e. a pole at a \
primitive {d}-th root of unity"
),
));
}
}
}
let mut acc = field.zero();
for (c, s) in coeffs.iter().zip(sums.iter()) {
acc = field.add(&acc, &field.mul(c, s));
}
if !acc.is_zero() {
return Err(QHolonomicError::CertificateVerificationFailed(format!(
"the specialised recurrence failed its own re-check in Q(zeta_{d}) at n = {n0}; \
refusing to return it"
)));
}
let mut termwise_regular = true;
for i in 0..=order as i64 {
let m = n0 + i;
let (lo, hi) = cert.term.window_at(m, n_min)?;
for k in lo..=hi {
let Some(v) = cert.term.value_at(m, k) else {
termwise_regular = false;
break;
};
if field.specialize(&v).is_none() {
termwise_regular = false;
break;
}
}
if !termwise_regular {
break;
}
}
let mut effective_support = Vec::new();
let mut generic_support_size = 0_usize;
for k in window.0..=window.1 {
let Some(v) = cert.term.value_at(n0, k) else {
continue;
};
if rn_is_zero(&v) {
continue;
}
generic_support_size += 1;
match field.specialize(&v) {
Some(sv) if !sv.is_zero() => effective_support.push(k),
_ => {}
}
}
Ok(QRootOfUnitySpecialization {
d,
n0,
status: QRootOfUnityStatus::Specializes,
field,
window: Some(window),
sums,
sum_valuations,
coeffs,
effective_support,
generic_support_size,
termwise_regular,
})
}
fn unknown(field: CycloField, n0: i64, reason: String) -> QRootOfUnitySpecialization {
QRootOfUnitySpecialization {
d: field.order(),
n0,
status: QRootOfUnityStatus::Unknown { reason },
field,
window: None,
sums: Vec::new(),
sum_valuations: Vec::new(),
coeffs: Vec::new(),
effective_support: Vec::new(),
generic_support_size: 0,
termwise_regular: false,
}
}
fn obstructed(
field: CycloField,
n0: i64,
d: u32,
window: (i64, i64),
sum_valuations: Vec<Option<i64>>,
reason: String,
) -> QRootOfUnitySpecialization {
QRootOfUnitySpecialization {
d,
n0,
status: QRootOfUnityStatus::Obstructed { reason },
field,
window: Some(window),
sums: Vec::new(),
sum_valuations,
coeffs: Vec::new(),
effective_support: Vec::new(),
generic_support_size: 0,
termwise_regular: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::AlkahestError;
use crate::holonomic::qzeil::cyclotomic::cyclotomic_polynomial;
use crate::holonomic::qzeil::{q_zeilberger, QZeilbergerOpts};
use crate::kernel::{Domain, ExprId, ExprPool};
use rug::{Integer, Rational};
fn syms(pool: &ExprPool) -> (ExprId, ExprId, ExprId) {
(
pool.symbol("q", Domain::Real),
pool.symbol("n", Domain::Real),
pool.symbol("k", Domain::Real),
)
}
fn qbinom(pool: &ExprPool, top: ExprId, bot: ExprId) -> ExprId {
pool.func("qbinomial", vec![top, bot])
}
fn vandermonde_cert(pool: &ExprPool) -> (QCertificate, ExprId, ExprId, ExprId) {
let (q, n, k) = syms(pool);
let b = qbinom(pool, n, k);
let f = pool.mul(vec![b, b, pool.pow(q, pool.mul(vec![k, k]))]);
let cert = q_zeilberger(f, q, n, k, pool, &QZeilbergerOpts::default())
.expect("the q-Vandermonde square sum must be decided")
.value;
(cert, q, n, k)
}
fn gaussian_binomial_at_zeta(f: &CycloField, nn: i64, kk: i64) -> CycloElem {
if kk < 0 || kk > nn {
return f.zero();
}
let mut row = vec![f.one()];
for _i in 1..=nn {
let mut next = vec![f.zero(); row.len() + 1];
for (j, cell) in row.iter().enumerate() {
next[j] = f.add(&next[j], &f.mul(cell, &f.zeta_pow(j as i64)));
next[j + 1] = f.add(&next[j + 1], cell);
}
row = next;
}
row[kk as usize].clone()
}
fn gaussian_binomial_by_q_lucas(f: &CycloField, nn: i64, kk: i64) -> CycloElem {
let d = f.order() as i64;
if kk < 0 || kk > nn {
return f.zero();
}
let (n1, n0) = (nn / d, nn % d);
let (k1, k0) = (kk / d, kk % d);
if k0 > n0 || k1 > n1 {
return f.zero();
}
let mut c = Integer::from(1);
for j in 0..k1 {
c *= Integer::from(n1 - j);
c /= Integer::from(j + 1);
}
let one = f.one();
let mut small = one.clone();
for i in 1..=k0 {
let num = f.sub(&one, &f.zeta_pow(n0 - k0 + i));
let den = f.sub(&one, &f.zeta_pow(i));
let inv = f.inv(&den).expect("1 − zeta^i is a unit for 0 < i < d");
small = f.mul(&small, &f.mul(&num, &inv));
}
f.mul(&small, &f.from_rational(Rational::from(c)))
}
fn gaussian_binomial_valuation(d: i64, nn: i64, kk: i64) -> i64 {
nn.div_euclid(d) - kk.div_euclid(d) - (nn - kk).div_euclid(d)
}
#[test]
fn the_yardsticks_agree_with_each_other() {
for d in 1_u32..=6 {
let f = CycloField::new(d).expect("in range");
for nn in 0..12 {
for kk in 0..=nn {
assert_eq!(
gaussian_binomial_at_zeta(&f, nn, kk),
gaussian_binomial_by_q_lucas(&f, nn, kk),
"Pascal and q-Lucas disagree on [{nn};{kk}]_zeta_{d}"
);
}
}
}
}
#[test]
fn q_vandermonde_square_sum_at_a_root_of_unity() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
for d in 1_u32..=6 {
let f = CycloField::new(d).expect("in range");
for n0 in 0..9_i64 {
let spec = q_specialize_at_root_of_unity(&cert, d, n0)
.expect("a polynomial summand has no pole anywhere");
assert_eq!(
spec.status.tag(),
"specializes",
"d = {d}, n = {n0}: {}",
spec.status.reason()
);
assert!(spec.is_termwise_regular());
let mut direct = f.zero();
for k in 0..=n0 {
let b = gaussian_binomial_at_zeta(&f, n0, k);
let sq = f.mul(&b, &b);
direct = f.add(&direct, &f.mul(&sq, &f.zeta_pow(k * k)));
}
assert_eq!(
spec.sums[0], direct,
"d = {d}, n = {n0}: the specialised sum must equal the sum computed \
independently in Q(zeta_{d})"
);
let lucas = gaussian_binomial_by_q_lucas(&f, 2 * n0, n0);
assert_eq!(
spec.sums[0], lucas,
"d = {d}, n = {n0}: sum_k [n;k]^2 q^(k^2) at zeta_{d} must be the q-Lucas \
value of [2n;n]"
);
let want_v = gaussian_binomial_valuation(d as i64, 2 * n0, n0);
assert_eq!(
spec.sum_valuations[0],
Some(want_v),
"d = {d}, n = {n0}: Phi_{d}(q)^{want_v} must divide [2n;n]_q exactly"
);
assert_eq!(want_v > 0, spec.sums[0].is_zero());
}
}
}
#[test]
fn the_specialised_recurrence_annihilates_the_independent_values() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let mut nontrivial = 0;
for d in 2_u32..=6 {
let f = CycloField::new(d).expect("in range");
for n0 in 0..8_i64 {
let spec = q_specialize_at_root_of_unity(&cert, d, n0).expect("no pole");
if !spec.specializes() || spec.is_vacuous() {
continue;
}
nontrivial += 1;
let mut acc = f.zero();
for (i, c) in spec.coeffs.iter().enumerate() {
let s = gaussian_binomial_by_q_lucas(&f, 2 * (n0 + i as i64), n0 + i as i64);
acc = f.add(&acc, &f.mul(c, &s));
}
assert!(
acc.is_zero(),
"d = {d}, n = {n0}: the specialised recurrence must annihilate the q-Lucas \
values, not merely the ones it computed itself"
);
}
}
assert!(nontrivial >= 10, "the check must not be vacuously empty");
}
#[test]
fn the_support_shrinks_at_a_root_of_unity_and_says_so() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let spec = q_specialize_at_root_of_unity(&cert, 2, 2).expect("no pole");
assert_eq!(spec.status.tag(), "specializes");
assert_eq!(spec.window, Some((0, 2)));
assert_eq!(spec.generic_support_size, 3);
assert_eq!(
spec.effective_support,
vec![0, 2],
"the k = 1 term must die at zeta_2, since [2;1]_q = 1 + q"
);
assert!(spec.support_shrinks());
assert!(spec
.side_conditions()
.iter()
.any(|s| s.contains("the support shrank under specialisation")));
let f = CycloField::new(2).expect("in range");
assert_eq!(spec.sums[0], f.from_rational(Rational::from(2)));
}
#[test]
fn the_support_never_grows() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
for d in 1_u32..=5 {
let f = CycloField::new(d).expect("in range");
for n0 in 0..6_i64 {
for k in [-3_i64, -1, n0 + 1, n0 + 4] {
let v = cert.term.value_at(n0, k).expect("finite");
assert_eq!(
f.specialize(&v),
Some(f.zero()),
"d = {d}, n = {n0}, k = {k}: outside the window the specialisation must \
still be zero"
);
}
}
}
}
#[test]
fn the_classical_limit_is_a_vacuous_recurrence_with_correct_values() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let f = CycloField::new(1).expect("in range");
for n0 in 0..8_i64 {
let spec = q_specialize_at_root_of_unity(&cert, 1, n0).expect("no pole");
assert_eq!(spec.status.tag(), "specializes");
assert!(
spec.is_vacuous(),
"at q = 1 the coefficients (1 − q^(n+1))² and (1 − q^(2n+1))(1 − q^(2n+2)) all \
vanish, so the specialised recurrence says nothing"
);
assert!(!spec.leading_coefficient_survives());
assert!(spec.side_conditions().iter().any(|s| s.contains("VACUOUS")));
let mut c = Integer::from(1);
for j in 0..n0 {
c *= Integer::from(2 * n0 - j);
c /= Integer::from(j + 1);
}
assert_eq!(
spec.sums[0],
f.from_rational(Rational::from(c)),
"at q = 1 the sum must be the central binomial coefficient C(2n, n)"
);
}
}
#[test]
fn a_root_of_unity_can_kill_the_leading_coefficient() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let f = CycloField::new(2).expect("in range");
let spec = q_specialize_at_root_of_unity(&cert, 2, 1).expect("no pole");
assert_eq!(spec.status.tag(), "specializes");
assert!(!spec.is_vacuous(), "a_0 survives, so this is not vacuous");
assert!(
!spec.leading_coefficient_survives(),
"a_1(zeta_2^1) is a multiple of (1 − q^2)|_{{q=−1}} = 0"
);
assert!(spec
.side_conditions()
.iter()
.any(|s| s.contains("the leading coefficient")));
assert!(!spec.coeffs[0].is_zero());
assert_eq!(spec.sums[0], f.zero());
assert_eq!(spec.sum_valuations[0], Some(1));
}
#[test]
fn a_pole_at_the_root_of_unity_is_obstructed_not_specialised() {
let pool = ExprPool::new();
let (q, n, k) = syms(&pool);
let b = qbinom(&pool, n, k);
let pole = pool.pow(
pool.func(
"qpochhammer",
vec![
pool.integer(3_i32),
pool.integer(3_i32),
pool.integer(1_i32),
],
),
pool.integer(-1_i32),
);
let f = pool.mul(vec![b, b, pool.pow(q, pool.mul(vec![k, k])), pole]);
let cert = q_zeilberger(f, q, n, k, &pool, &QZeilbergerOpts::default())
.expect("the certificate itself is fine — the constant factor cancels out of it")
.value;
assert_eq!(cert.boundary.tag(), "vanishes");
let mut obstructed = 0;
for n0 in 0..7_i64 {
let spec =
q_specialize_at_root_of_unity(&cert, 3, n0).expect("a verdict, not an error");
assert_eq!(
spec.status.tag(),
"obstructed",
"n = {n0}: 1/(1 − q³) has a pole at zeta_3 and must not be specialised"
);
assert!(!spec.specializes());
assert!(spec.sums.is_empty(), "no specialised value may be offered");
assert!(spec.status.reason().contains("valuation"));
assert!(spec
.side_conditions()
.iter()
.any(|s| s.contains("obstructed")));
obstructed += 1;
}
assert_eq!(obstructed, 7);
let spec = q_specialize_at_root_of_unity(&cert, 5, 2).expect("no pole");
assert_eq!(spec.status.tag(), "specializes", "{}", spec.status.reason());
}
#[test]
fn an_unknown_generic_verdict_stays_unknown_at_a_root_of_unity() {
let pool = ExprPool::new();
let (q, n, k) = syms(&pool);
let f = pool.pow(
pool.func(
"qpochhammer",
vec![
pool.integer(1_i32),
pool.integer(1_i32),
pool.add(vec![n, pool.mul(vec![k, pool.integer(-1_i32)])]),
],
),
pool.integer(-1_i32),
);
let cert = q_zeilberger(f, q, n, k, &pool, &QZeilbergerOpts::default())
.expect("the telescoping identity is fine")
.value;
assert_eq!(cert.boundary.tag(), "unknown");
let spec = q_specialize_at_root_of_unity(&cert, 3, 2).expect("a verdict, not an error");
assert_eq!(spec.status.tag(), "unknown");
assert!(!spec.specializes());
assert!(spec.sums.is_empty());
assert!(spec.status.reason().contains("already"));
}
#[test]
fn malformed_requests_are_refused() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let err = q_specialize_at_root_of_unity(&cert, 0, 3).expect_err("d = 0 names nothing");
assert_eq!(err.code(), "E-HOLO-023");
let err = q_specialize_at_root_of_unity(&cert, 100_000, 3).expect_err("past the cap");
assert_eq!(err.code(), "E-HOLO-023");
let err = q_specialize_at_root_of_unity(&cert, 3, -1)
.expect_err("below the range the verdict covers");
assert_eq!(err.code(), "E-HOLO-023");
}
#[test]
fn the_modulus_is_exposed_for_independent_checking() {
let pool = ExprPool::new();
let (cert, _q, _n, _k) = vandermonde_cert(&pool);
let spec = q_specialize_at_root_of_unity(&cert, 6, 3).expect("no pole");
assert_eq!(spec.field.modulus(), &cyclotomic_polynomial(6));
assert_eq!(spec.field.degree(), 2); }
}