use crate::ftp::Program;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
type Poly = BTreeMap<BTreeSet<usize>, f64>;
fn add(p: &mut Poly, mono: BTreeSet<usize>, c: f64) {
if c == 0.0 {
return;
}
let e = p.entry(mono).or_insert(0.0);
*e += c;
if *e == 0.0 {
let key = p.iter().find(|(_, v)| **v == 0.0).map(|(k, _)| k.clone());
if let Some(k) = key {
p.remove(&k);
}
}
}
fn degree(p: &Poly) -> usize {
p.keys().map(|m| m.len()).max().unwrap_or(0)
}
#[derive(Clone, Debug, PartialEq)]
pub struct Reduction {
pub program: Program,
pub ancillas: usize,
pub original_spins: usize,
pub penalty: f64,
pub offset: f64,
}
impl Reduction {
pub fn project<'a>(&self, state: &'a [i8]) -> &'a [i8] {
&state[..self.original_spins.min(state.len())]
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ReduceError {
TooWide { arity: usize, limit: usize },
Empty,
}
impl core::fmt::Display for ReduceError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ReduceError::TooWide { arity, limit } => write!(
f,
"a factor of arity {arity} expands to 2^{arity} binary monomials, over the {limit} \
this will attempt; split the term before reducing it"
),
ReduceError::Empty => write!(f, "nothing to reduce"),
}
}
}
pub const MAX_ARITY: usize = 20;
pub fn to_pairwise(p: &Program) -> Result<Reduction, ReduceError> {
if let Some(f) = p.factors.iter().find(|f| f.arity() > MAX_ARITY) {
return Err(ReduceError::TooWide { arity: f.arity(), limit: MAX_ARITY });
}
let mut poly: Poly = BTreeMap::new();
for f in &p.factors {
let vars: Vec<usize> = f.vars().collect();
expand_spin_product(&mut poly, &vars, -f.weight());
}
for &(i, h) in &p.bias {
expand_spin_product(&mut poly, &[i], -h);
}
let mut next = p.spins;
let mut ancillas = 0usize;
let scale: f64 = poly.values().map(|v| v.abs()).sum();
let penalty = if scale > 0.0 { scale * 2.0 } else { 1.0 };
while degree(&poly) > 2 {
let mut count: BTreeMap<(usize, usize), usize> = BTreeMap::new();
for m in poly.keys().filter(|m| m.len() > 2) {
let v: Vec<usize> = m.iter().copied().collect();
for i in 0..v.len() {
for j in (i + 1)..v.len() {
*count.entry((v[i], v[j])).or_insert(0) += 1;
}
}
}
let (a, b) = *count
.iter()
.max_by_key(|(_, n)| **n)
.map(|(k, _)| k)
.expect("degree > 2 means some monomial has a pair");
let y = next;
next += 1;
ancillas += 1;
let mut rewritten: Poly = BTreeMap::new();
for (m, c) in poly.iter() {
if m.len() > 2 && m.contains(&a) && m.contains(&b) {
let mut n: BTreeSet<usize> = m.iter().copied().filter(|v| *v != a && *v != b).collect();
n.insert(y);
add(&mut rewritten, n, *c);
} else {
add(&mut rewritten, m.clone(), *c);
}
}
add(&mut rewritten, BTreeSet::from([y]), 3.0 * penalty);
add(&mut rewritten, BTreeSet::from([a, b]), penalty);
add(&mut rewritten, BTreeSet::from([a, y]), -2.0 * penalty);
add(&mut rewritten, BTreeSet::from([b, y]), -2.0 * penalty);
poly = rewritten;
}
let (program, offset) = to_program(&poly, next)?;
Ok(Reduction { program, ancillas, original_spins: p.spins, penalty, offset })
}
fn expand_spin_product(poly: &mut Poly, vars: &[usize], c: f64) {
let k = vars.len();
for mask in 0u32..(1u32 << k) {
let mut mono = BTreeSet::new();
let mut taken = 0;
for (bit, &v) in vars.iter().enumerate() {
if mask & (1 << bit) != 0 {
mono.insert(v);
taken += 1;
}
}
let sign = if (k - taken).is_multiple_of(2) { 1.0 } else { -1.0 };
add(poly, mono, c * sign * (1u64 << taken) as f64);
}
}
fn to_program(poly: &Poly, spins: usize) -> Result<(Program, f64), ReduceError> {
let mut offset = 0.0;
let mut lin: BTreeMap<usize, f64> = BTreeMap::new();
let mut quad: BTreeMap<(usize, usize), f64> = BTreeMap::new();
for (m, c) in poly {
match m.len() {
0 => offset += c,
1 => {
let i = *m.iter().next().unwrap();
offset += c / 2.0;
*lin.entry(i).or_insert(0.0) += c / 2.0;
}
2 => {
let v: Vec<usize> = m.iter().copied().collect();
offset += c / 4.0;
*lin.entry(v[0]).or_insert(0.0) += c / 4.0;
*lin.entry(v[1]).or_insert(0.0) += c / 4.0;
*quad.entry((v[0], v[1])).or_insert(0.0) += c / 4.0;
}
n => return Err(ReduceError::TooWide { arity: n, limit: 2 }),
}
}
let mut src = format!("ftp 1\nname reduced\nspins {spins}\n");
for ((i, j), w) in &quad {
if *w != 0.0 {
src.push_str(&format!("factor {} {i} {j}\n", -w));
}
}
for (i, h) in &lin {
if *h != 0.0 {
src.push_str(&format!("bias {i} {}\n", -h));
}
}
let program = Program::from_ftp(&src).map_err(|_| ReduceError::Empty)?;
Ok((program, offset))
}
#[cfg(test)]
mod tests {
use super::*;
fn energy(p: &Program, s: &[i8]) -> f64 {
let mut e = 0.0;
for f in &p.factors {
let prod: f64 = f.vars().map(|v| s[v] as f64).product();
e -= f.weight() * prod;
}
for &(i, h) in &p.bias {
e -= h * s[i] as f64;
}
e
}
fn minimised_over_ancillas(r: &Reduction, orig: &[i8]) -> f64 {
let k = r.ancillas;
let mut best = f64::INFINITY;
for mask in 0u32..(1u32 << k) {
let mut s = orig.to_vec();
for a in 0..k {
s.push(if mask & (1 << a) != 0 { 1 } else { -1 });
}
best = best.min(energy(&r.program, &s));
}
best
}
fn agrees_everywhere(src: &str) -> Reduction {
let p = Program::from_ftp(src).unwrap();
let r = to_pairwise(&p).unwrap();
assert!(
r.program.factors.iter().all(|f| f.arity() <= 2),
"the point of the pass is that nothing is wider than two"
);
let n = p.spins;
let mut delta: Option<f64> = None;
for mask in 0u32..(1u32 << n) {
let s: Vec<i8> = (0..n).map(|i| if mask & (1 << i) != 0 { 1 } else { -1 }).collect();
let want = energy(&p, &s);
let got = minimised_over_ancillas(&r, &s);
let d = got - want;
match delta {
None => delta = Some(d),
Some(d0) => assert!(
(d - d0).abs() < 1e-9,
"state {s:?}: original {want}, reduced {got}, offset {d} but {d0} elsewhere — \
the reduction reordered states rather than shifting them"
),
}
}
r
}
#[test]
fn a_three_body_term_becomes_pairwise_without_moving_any_state() {
let r = agrees_everywhere("ftp 1\nspins 3\nfactor 1.0 0 1 2\n");
assert_eq!(r.ancillas, 1, "one pair replaced, one ancilla");
assert_eq!(r.original_spins, 3);
}
#[test]
fn a_four_body_term_and_a_negative_weight() {
agrees_everywhere("ftp 1\nspins 4\nfactor -2.5 0 1 2 3\n");
}
#[test]
fn several_higher_order_terms_sharing_variables() {
let r = agrees_everywhere(
"ftp 1\nspins 5\nfactor 1.0 0 1 2\nfactor 1.0 0 1 3\nfactor -1.0 0 1 4\nbias 2 0.5\n",
);
assert_eq!(r.ancillas, 1, "one ancilla for the pair (0,1) serves all three terms");
}
#[test]
fn mixed_orders_including_terms_already_pairwise() {
agrees_everywhere(
"ftp 1\nspins 4\nfactor 1.0 0 1\nfactor 0.5 0 1 2\nfactor -1.0 1 2 3\nbias 0 0.25\n",
);
}
#[test]
fn a_pairwise_program_is_returned_untouched() {
let p = Program::from_ftp("ftp 1\nspins 3\nfactor 1.0 0 1\nfactor 1.0 1 2\nbias 0 0.5\n")
.unwrap();
let r = to_pairwise(&p).unwrap();
assert_eq!(r.ancillas, 0, "nothing to reduce");
assert_eq!(r.program.spins, 3, "and no spins added");
for mask in 0u32..8 {
let s: Vec<i8> = (0..3).map(|i| if mask & (1 << i) != 0 { 1 } else { -1 }).collect();
assert!((energy(&p, &s) - (energy(&r.program, &s) + r.offset)).abs() < 1e-9);
}
}
#[test]
fn the_ground_state_of_the_reduction_projects_to_the_original_ground_state() {
let src = "ftp 1\nspins 4\nfactor 1.0 0 1 2\nfactor -1.5 1 2 3\nbias 0 0.3\nbias 3 -0.2\n";
let p = Program::from_ftp(src).unwrap();
let r = to_pairwise(&p).unwrap();
let best_original = (0u32..(1 << p.spins))
.map(|m| {
let s: Vec<i8> =
(0..p.spins).map(|i| if m & (1 << i) != 0 { 1 } else { -1 }).collect();
(energy(&p, &s) * 1e9) as i64
})
.min()
.unwrap();
let mut best = f64::INFINITY;
let mut best_state = Vec::new();
for m in 0u32..(1 << r.program.spins) {
let s: Vec<i8> = (0..r.program.spins)
.map(|i| if m & (1 << i) != 0 { 1 } else { -1 })
.collect();
let e = energy(&r.program, &s);
if e < best {
best = e;
best_state = s;
}
}
let projected = r.project(&best_state);
assert_eq!(projected.len(), p.spins, "the ancillas are not part of the answer");
assert_eq!(
(energy(&p, projected) * 1e9) as i64,
best_original,
"the projected state must be an optimum of the original: {projected:?}"
);
}
#[test]
fn a_higher_order_program_runs_end_to_end_on_a_pairwise_fabric() {
use crate::fabric::{Cpu, Device, Fabric, Unsupported};
use crate::ledger::Z1_SPICE;
let src = "ftp 1\nspins 5\nfactor 1.0 0 1 2\nfactor -1.5 2 3 4\nbias 0 0.4\n";
let p = Program::from_ftp(src).unwrap();
let dw = Fabric::dwave_advantage2(Z1_SPICE);
let refusal = dw.check(&p);
assert!(
refusal.iter().any(|u| matches!(u, Unsupported::ArityTooHigh { .. })),
"{refusal:?}"
);
assert!(
refusal.iter().any(|u| u.to_string().contains("to_pairwise")),
"the refusal names the remedy: {refusal:?}"
);
let r = to_pairwise(&p).unwrap();
assert!(r.ancillas > 0);
assert!(
!Fabric::unconstrained("sim", Z1_SPICE).check(&r.program).iter()
.any(|u| matches!(u, Unsupported::ArityTooHigh { .. })),
"nothing is wider than two now"
);
let mut cpu = Cpu::default();
assert!(!cpu.program(&p).is_empty(), "the original does not run");
assert!(cpu.program(&r.program).is_empty(), "{:?}", cpu.program(&r.program));
let best = crate::exact::Elimination::default()
.ground_state(&r.program.to_graph().unwrap())
.unwrap()
.ground_state
.expect("a ground state");
let projected = r.project(&best);
let want = (0u32..(1 << p.spins))
.map(|m| {
let s: Vec<i8> =
(0..p.spins).map(|i| if m & (1 << i) != 0 { 1 } else { -1 }).collect();
(energy(&p, &s) * 1e9) as i64
})
.min()
.unwrap();
assert_eq!(
(energy(&p, projected) * 1e9) as i64,
want,
"projected {projected:?} must minimise the model that was written"
);
}
#[test]
fn a_factor_too_wide_to_expand_is_refused_rather_than_attempted() {
let vars: Vec<String> = (0..25).map(|i| i.to_string()).collect();
let src = format!("ftp 1\nspins 30\nfactor 1.0 {}\n", vars.join(" "));
let p = Program::from_ftp(&src).unwrap();
match to_pairwise(&p) {
Err(ReduceError::TooWide { arity: 25, limit: 20 }) => {}
other => panic!("2^25 monomials should be refused, got {other:?}"),
}
}
#[test]
fn the_penalty_outweighs_the_model_it_guards() {
let p = Program::from_ftp("ftp 1\nspins 3\nfactor 100.0 0 1 2\n").unwrap();
let r = to_pairwise(&p).unwrap();
assert!(r.penalty > 100.0, "penalty {} against a weight of 100", r.penalty);
}
}