pub mod cert;
pub mod gram;
pub mod linalg;
pub mod lp;
pub mod psd;
pub mod ratpoly;
pub mod sdp;
pub use cert::{CertificateKind, Multiplier, PositivityCertificate, SosPoly, SosTerm};
pub use ratpoly::RatPoly;
use crate::kernel::{ExprId, ExprPool};
use lp::{Lp, LpStatus, Rel};
use ratpoly::Exponents;
use rug::Rational;
use std::fmt;
#[derive(Debug, Clone, Copy)]
pub struct SosOpts {
pub basis_degree: Option<u32>,
pub level: u32,
}
impl Default for SosOpts {
fn default() -> Self {
SosOpts {
basis_degree: None,
level: 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SosError {
NotPolynomial(String),
NoCertificate(String),
Negative(String),
InvalidInput(String),
VerificationFailed(String),
}
impl fmt::Display for SosError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SosError::NotPolynomial(s) => write!(f, "sos: not a polynomial: {s}"),
SosError::NoCertificate(s) => write!(f, "sos: no certificate found: {s}"),
SosError::Negative(s) => write!(f, "sos: the target is negative somewhere: {s}"),
SosError::InvalidInput(s) => write!(f, "sos: invalid input: {s}"),
SosError::VerificationFailed(s) => {
write!(f, "sos: certificate failed exact verification: {s}")
}
}
}
}
impl std::error::Error for SosError {}
impl crate::errors::AlkahestError for SosError {
fn code(&self) -> &'static str {
match self {
SosError::NotPolynomial(_) => "E-SOS-001",
SosError::NoCertificate(_) => "E-SOS-002",
SosError::Negative(_) => "E-SOS-003",
SosError::InvalidInput(_) => "E-SOS-004",
SosError::VerificationFailed(_) => "E-SOS-005",
}
}
fn remediation(&self) -> Option<&'static str> {
Some(match self {
SosError::NotPolynomial(_) => {
"positivity certificates are for polynomials in the listed variables; expand or \
clear denominators first, and pass every symbol that occurs as a variable"
}
SosError::NoCertificate(_) => {
"record this as unknown, not as a closed branch: raise basis_degree \
(unconstrained) or level (constrained); the search covers the diagonally \
dominant subcone, so this is not a proof that no SOS decomposition exists, \
and still less that the inequality is false — alkahest.decide is the \
complete (and far more expensive) fallback"
}
SosError::Negative(_) => {
"the witness point in the message satisfies the constraints and makes the target \
negative; the claim is false as stated"
}
SosError::InvalidInput(_) => {
"pass at least one variable, and keep basis_degree/level within the supported range"
}
SosError::VerificationFailed(_) => {
"internal: report the target and constraints as a minimal failing example"
}
})
}
}
fn negativity_witness(
target: &RatPoly,
constraints: &[RatPoly],
nvars: usize,
) -> Option<Vec<Rational>> {
const GRID: [(i32, i32); 9] = [
(0, 1),
(1, 1),
(-1, 1),
(1, 2),
(-1, 2),
(2, 1),
(-2, 1),
(3, 1),
(-3, 1),
];
let per_var = if nvars <= 2 {
GRID.len()
} else if nvars <= 4 {
5
} else {
3
};
let total = per_var.checked_pow(nvars as u32)?;
if total > 20_000 {
return None;
}
for idx in 0..total {
let mut rest = idx;
let mut point = Vec::with_capacity(nvars);
for _ in 0..nvars {
let (num, den) = GRID[rest % per_var];
rest /= per_var;
point.push(Rational::from((num, den)));
}
if constraints.iter().any(|g| g.eval(&point) < 0) {
continue;
}
if target.eval(&point) < 0 {
return Some(point);
}
}
None
}
fn format_point(names: &[String], point: &[Rational]) -> String {
names
.iter()
.zip(point.iter())
.map(|(n, v)| format!("{n} = {v}"))
.collect::<Vec<_>>()
.join(", ")
}
fn var_names(vars: &[ExprId], pool: &ExprPool) -> Vec<String> {
vars.iter().map(|&v| pool.display(v).to_string()).collect()
}
fn finish(cert: PositivityCertificate) -> Result<PositivityCertificate, SosError> {
match cert.verify() {
Ok(()) => Ok(cert),
Err(why) => Err(SosError::VerificationFailed(why)),
}
}
pub(crate) const MAX_MULTIPLIER_POWER: u32 = 4;
const MAX_MULTIPLIER_BASIS_LEN: usize = 90;
fn multiplier_search(
target: &RatPoly,
nvars: usize,
max_power: u32,
max_basis_len: usize,
log: &mut Vec<String>,
) -> Option<(RatPoly, SosPoly, u32)> {
for n in 1..=max_power {
let sigma = RatPoly::sum_of_squares(nvars).pow(n);
let q = target.mul(&sigma);
let qdeg = q.total_degree();
if qdeg % 2 != 0 {
continue;
}
let basis_deg = qdeg.div_ceil(2);
let basis_len = gram::monomial_basis(nvars, basis_deg).len();
if basis_len > max_basis_len {
log.push(format!(
"multiplier search: N={n} would need a degree-{basis_deg} basis \
({basis_len} monomials), over the search budget ({max_basis_len}); stopping"
));
break;
}
log.push(format!(
"multiplier search: trying σ = (Σxᵢ²)^{n}, searching the full PSD Gram cone for \
σ·p over the degree-{basis_deg} monomial basis ({basis_len} monomials)"
));
if let Some(sos) = psd::psd_search(&q, basis_deg) {
log.push(format!("multiplier search succeeded at N={n}"));
return Some((sigma, sos, basis_deg));
}
}
None
}
pub fn sos_decompose(
expr: ExprId,
vars: &[ExprId],
pool: &ExprPool,
opts: &SosOpts,
) -> Result<PositivityCertificate, SosError> {
if vars.is_empty() {
return Err(SosError::InvalidInput(
"at least one variable is required".into(),
));
}
let names = var_names(vars, pool);
let target = RatPoly::from_expr(expr, vars, pool).map_err(SosError::NotPolynomial)?;
let nvars = vars.len();
let mut log = Vec::new();
if let Some(c) = target.as_constant() {
if c < 0 {
return Err(SosError::Negative(format!(
"the target is the negative constant {c}"
)));
}
let mut sos = SosPoly::default();
sos.push(c.clone(), RatPoly::one(nvars));
log.push(format!("target is the non-negative constant {c}"));
return finish(PositivityCertificate {
vars: vars.to_vec(),
var_names: names,
target,
constraints: Vec::new(),
kind: CertificateKind::Sos,
degree: 0,
terms: vec![Multiplier {
constraints: Vec::new(),
sos,
}],
log,
});
}
let deg = target.total_degree();
if let Some(point) = negativity_witness(&target, &[], nvars) {
return Err(SosError::Negative(format!(
"p({}) = {} < 0",
format_point(&names, &point),
target.eval(&point)
)));
}
if deg % 2 == 1 {
return Err(SosError::NoCertificate(format!(
"total degree {deg} is odd, so p cannot be a sum of squares (no witness point was \
found on the sampling grid, so this is a statement about the SOS question, not a \
claim that p is negative)"
)));
}
let basis_deg = opts.basis_degree.unwrap_or(deg.div_ceil(2));
if basis_deg > 12 {
return Err(SosError::InvalidInput(
"basis_degree above 12 is refused: the monomial basis (and the exact LP over it) \
grows too fast to be useful"
.into(),
));
}
log.push(format!(
"searching the diagonally dominant cone over the degree-{basis_deg} monomial basis"
));
let Some(sos) = gram::dsos_search(&target, basis_deg) else {
log.push(
"diagonally dominant search failed; trying the full PSD Gram cone directly".to_string(),
);
if let Some(sos) = psd::psd_search(&target, basis_deg) {
log.push(
"full PSD Gram search succeeded (p is SOS but its Gram matrix is not \
diagonally dominant)"
.to_string(),
);
return finish(PositivityCertificate {
vars: vars.to_vec(),
var_names: names,
target,
constraints: Vec::new(),
kind: CertificateKind::Sos,
degree: basis_deg,
terms: vec![Multiplier {
constraints: Vec::new(),
sos,
}],
log,
});
}
if let Some((_sigma, sos, mult_basis_deg)) = multiplier_search(
&target,
nvars,
MAX_MULTIPLIER_POWER,
MAX_MULTIPLIER_BASIS_LEN,
&mut log,
) {
return finish(PositivityCertificate {
vars: vars.to_vec(),
var_names: names,
target,
constraints: Vec::new(),
kind: CertificateKind::Sos,
degree: mult_basis_deg,
terms: vec![Multiplier {
constraints: Vec::new(),
sos,
}],
log,
});
}
return Err(SosError::NoCertificate(format!(
"undecided, not a refutation — no diagonally dominant or general PSD Gram matrix \
over the degree-{basis_deg} monomial basis reproduces p, and no Reznick multiplier \
(Σxᵢ²)^N up to N={MAX_MULTIPLIER_POWER} made σ·p SOS within the search budget \
either. None of this is a proof that p is not SOS (with or without a multiplier), \
and still less that p is not non-negative — only that no certificate of these \
shapes was found at this size. Raise basis_degree, or fall back to alkahest.decide"
)));
};
finish(PositivityCertificate {
vars: vars.to_vec(),
var_names: names,
target,
constraints: Vec::new(),
kind: CertificateKind::Sos,
degree: basis_deg,
terms: vec![Multiplier {
constraints: Vec::new(),
sos,
}],
log,
})
}
fn constraint_products(k: usize, level: u32) -> Vec<Vec<usize>> {
let mut out = vec![Vec::new()];
let mut frontier: Vec<Vec<usize>> = vec![Vec::new()];
for _ in 0..level {
let mut next = Vec::new();
for base in &frontier {
let start = base.last().copied().unwrap_or(0);
for i in start..k {
let mut v = base.clone();
v.push(i);
next.push(v);
}
}
out.extend(next.iter().cloned());
frontier = next;
}
out
}
pub fn prove_nonneg(
expr: ExprId,
constraints: &[ExprId],
vars: &[ExprId],
pool: &ExprPool,
opts: &SosOpts,
) -> Result<PositivityCertificate, SosError> {
if constraints.is_empty() {
return sos_decompose(expr, vars, pool, opts);
}
if vars.is_empty() {
return Err(SosError::InvalidInput(
"at least one variable is required".into(),
));
}
if opts.level == 0 || opts.level > 8 {
return Err(SosError::InvalidInput(
"level must be between 1 and 8".into(),
));
}
let names = var_names(vars, pool);
let nvars = vars.len();
let target = RatPoly::from_expr(expr, vars, pool).map_err(SosError::NotPolynomial)?;
let gs: Vec<RatPoly> = constraints
.iter()
.map(|&g| RatPoly::from_expr(g, vars, pool).map_err(SosError::NotPolynomial))
.collect::<Result<_, _>>()?;
if let Some(point) = negativity_witness(&target, &gs, nvars) {
return Err(SosError::Negative(format!(
"p({}) = {} < 0 at a point satisfying every constraint",
format_point(&names, &point),
target.eval(&point)
)));
}
let products = constraint_products(gs.len(), opts.level);
let expanded: Vec<RatPoly> = products
.iter()
.map(|idxs| {
let mut acc = RatPoly::one(nvars);
for &i in idxs {
acc = acc.mul(&gs[i]);
}
acc
})
.collect();
let mut monomials: std::collections::BTreeSet<Exponents> = Default::default();
for e in &expanded {
monomials.extend(e.terms().keys().cloned());
}
monomials.extend(target.terms().keys().cloned());
let mut prog = Lp::new(expanded.len());
for m in &monomials {
let row: Vec<Rational> = expanded.iter().map(|e| e.coeff(m)).collect();
prog.constrain(row, Rel::Eq, target.coeff(m));
}
let weights = match prog.solve() {
LpStatus::Optimal(w) => w,
_ => {
return Err(SosError::NoCertificate(format!(
"undecided, not a refutation — no non-negative combination of constraint \
products up to level {} reproduces the target. Raise level, or the claim may \
need a Putinar-style certificate with SOS (not merely non-negative constant) \
multipliers. Record this as unknown, not as a closed branch",
opts.level
)));
}
};
let mut terms = Vec::new();
for (idxs, w) in products.iter().zip(weights.iter()) {
if *w == 0 {
continue;
}
let mut sos = SosPoly::default();
sos.push(w.clone(), RatPoly::one(nvars));
terms.push(Multiplier {
constraints: idxs.clone(),
sos,
});
}
finish(PositivityCertificate {
vars: vars.to_vec(),
var_names: names,
target,
constraints: gs,
kind: CertificateKind::Handelman,
degree: opts.level,
terms,
log: vec![format!(
"Handelman search over {} constraint products up to level {}",
products.len(),
opts.level
)],
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::AlkahestError;
use crate::kernel::Domain;
fn setup() -> (ExprPool, ExprId, ExprId) {
let pool = ExprPool::new();
let x = pool.symbol("x", Domain::Real);
let y = pool.symbol("y", Domain::Real);
(pool, x, y)
}
#[test]
fn perfect_square_is_certified() {
let (pool, x, y) = setup();
let p = pool.add(vec![
pool.mul(vec![x, x]),
pool.mul(vec![pool.integer(-2_i32), x, y]),
pool.mul(vec![y, y]),
]);
let cert = sos_decompose(p, &[x, y], &pool, &SosOpts::default()).expect("certificate");
assert_eq!(cert.kind, CertificateKind::Sos);
cert.verify().expect("re-expands exactly");
assert!(cert.num_squares() >= 1);
}
#[test]
fn sum_of_even_powers_is_certified() {
let (pool, x, y) = setup();
let p = pool.add(vec![
pool.mul(vec![x, x, x, x]),
pool.mul(vec![y, y, y, y]),
pool.integer(1_i32),
]);
let cert = sos_decompose(p, &[x, y], &pool, &SosOpts::default()).expect("certificate");
cert.verify().expect("re-expands exactly");
}
#[test]
fn negative_polynomial_returns_a_witness_not_a_refusal() {
let (pool, x, y) = setup();
let p = pool.add(vec![pool.mul(vec![x, x]), pool.integer(-1_i32)]);
let err = sos_decompose(p, &[x, y], &pool, &SosOpts::default()).expect_err("negative");
assert!(matches!(err, SosError::Negative(_)));
assert_eq!(err.code(), "E-SOS-003");
}
#[test]
fn motzkin_certifies_via_a_reznick_multiplier() {
let (pool, x, y) = setup();
let p = pool.add(vec![
pool.mul(vec![x, x, x, x, y, y]),
pool.mul(vec![x, x, y, y, y, y]),
pool.mul(vec![pool.integer(-3_i32), x, x, y, y]),
pool.integer(1_i32),
]);
let cert =
sos_decompose(p, &[x, y], &pool, &SosOpts::default()).expect("Motzkin now certifies");
assert_eq!(cert.kind, CertificateKind::Sos);
let sigma = cert
.multiplier()
.expect("Motzkin is not itself SOS, so this must be a multiplier certificate");
assert_eq!(sigma.mul(&cert.target), cert.expand());
cert.verify().expect("re-verifies exactly end to end");
let lean = cert
.to_lean()
.expect("multiplier certificates emit Lean too");
assert!(!lean.contains("sorry"));
assert!(!lean.contains("admit"));
assert!(lean.contains("alkahest_multiplier_factor"));
assert!(lean.contains("alkahest_rhs_nonneg"));
assert!(lean.contains("sq_pos_of_ne_zero"));
assert!(lean.contains("ring"));
}
#[test]
fn multiplier_search_reports_undecided_not_not_sos_when_out_of_budget() {
let (pool, x, y) = setup();
let p = pool.add(vec![
pool.mul(vec![x, x, x, x, y, y]),
pool.mul(vec![x, x, y, y, y, y]),
pool.mul(vec![pool.integer(-3_i32), x, x, y, y]),
pool.integer(1_i32),
]);
let target = RatPoly::from_expr(p, &[x, y], &pool).unwrap();
let mut log = Vec::new();
let out = multiplier_search(&target, 2, 0, 90, &mut log);
assert!(
out.is_none(),
"a zero-power budget must not manufacture a certificate"
);
assert!(
negativity_witness(&target, &[], 2).is_none(),
"Motzkin is non-negative, so a witness must not exist"
);
}
#[test]
fn non_polynomial_is_refused() {
let (pool, x, y) = setup();
let p = pool.func("sin", vec![x]);
let err = sos_decompose(p, &[x, y], &pool, &SosOpts::default()).expect_err("not a poly");
assert_eq!(err.code(), "E-SOS-001");
}
#[test]
fn handelman_certifies_on_a_box() {
let (pool, x, _y) = setup();
let g1 = x;
let g2 = pool.add(vec![
pool.integer(1_i32),
pool.mul(vec![pool.integer(-1_i32), x]),
]);
let p = pool.add(vec![x, pool.mul(vec![pool.integer(-1_i32), x, x])]);
let cert =
prove_nonneg(p, &[g1, g2], &[x], &pool, &SosOpts::default()).expect("certificate");
assert_eq!(cert.kind, CertificateKind::Handelman);
cert.verify().expect("re-expands exactly");
}
#[test]
fn handelman_finds_the_witness_when_the_claim_is_false() {
let (pool, x, _y) = setup();
let g1 = x;
let p = pool.add(vec![x, pool.rational(-1_i32, 2_i32)]);
let err = prove_nonneg(p, &[g1], &[x], &pool, &SosOpts::default()).expect_err("negative");
assert!(matches!(err, SosError::Negative(_)));
}
}