use crate::ftp::Program;
use crate::ledger::Prices;
#[derive(Clone, Debug, PartialEq)]
pub enum Topology {
AllToAll,
Degree(usize),
Named(&'static str),
Unconstrained,
}
#[derive(Clone, Debug)]
pub struct Fabric {
pub name: &'static str,
pub topology: Topology,
pub max_spins: Option<usize>,
pub max_degree: Option<usize>,
pub coupling_bits: Option<u32>,
pub field_bits: Option<u32>,
pub supports_field: bool,
pub max_arity: usize,
pub uniform_couplings: bool,
pub prices: Prices,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Unsupported {
TooManySpins { need: usize, limit: usize },
TooHighDegree { node: usize, degree: usize, limit: usize },
ArityTooHigh { arity: usize, limit: usize },
NoFieldSupport { nodes: usize },
CouplingPrecision { bits: u32, worst_relative_error: f64 },
NonUniformCouplings { distinct: usize },
}
impl core::fmt::Display for Unsupported {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Unsupported::TooManySpins { need, limit } => {
write!(f, "the program needs {need} spins and this fabric has {limit}")
}
Unsupported::TooHighDegree { node, degree, limit } => write!(
f,
"spin {node} has degree {degree} and this fabric allows {limit}; sparsify the \
model or embed it before submitting"
),
Unsupported::ArityTooHigh { arity, limit } => write!(
f,
"a factor of arity {arity} cannot run on a fabric limited to {limit}; lower it to \
pairwise first"
),
Unsupported::NoFieldSupport { nodes } => write!(
f,
"{nodes} spins carry an external field and this fabric cannot apply one"
),
Unsupported::NonUniformCouplings { distinct } => write!(
f,
"this fabric counts active neighbours rather than weighting them, so every coupling \
must be equal; the program has {distinct} distinct weights. A spin glass cannot be \
expressed here at all"
),
Unsupported::CouplingPrecision { bits, worst_relative_error } => write!(
f,
"this fabric stores couplings in {bits} bits, which would change one of them by \
{:.1}% -- requantize explicitly if that is acceptable, rather than discovering it \
from the answers",
worst_relative_error * 100.0
),
}
}
}
impl Fabric {
pub fn unconstrained(name: &'static str, prices: Prices) -> Fabric {
Fabric {
name,
topology: Topology::Unconstrained,
max_spins: None,
max_degree: None,
coupling_bits: None,
field_bits: None,
supports_field: true,
max_arity: usize::MAX,
uniform_couplings: false,
prices,
}
}
pub fn check(&self, p: &Program) -> Vec<Unsupported> {
let mut out = Vec::new();
if let Some(limit) = self.max_spins {
if p.spins > limit {
out.push(Unsupported::TooManySpins { need: p.spins, limit });
}
}
let mut worst_arity = 0;
let mut degree = vec![0usize; p.spins];
for f in &p.factors {
worst_arity = worst_arity.max(f.arity());
if f.arity() == 2 {
for v in f.vars() {
if v < p.spins {
degree[v] += 1;
}
}
}
}
if worst_arity > self.max_arity {
out.push(Unsupported::ArityTooHigh { arity: worst_arity, limit: self.max_arity });
}
let deg_limit = match (&self.topology, self.max_degree) {
(Topology::Degree(d), _) => Some(*d),
(_, Some(d)) => Some(d),
_ => None,
};
if let Some(limit) = deg_limit {
if let Some((node, &d)) = degree.iter().enumerate().max_by_key(|(_, &d)| d) {
if d > limit {
out.push(Unsupported::TooHighDegree { node, degree: d, limit });
}
}
}
if !self.supports_field && !p.bias.is_empty() {
out.push(Unsupported::NoFieldSupport { nodes: p.bias.len() });
}
if self.uniform_couplings {
let mut seen: Vec<u64> = p.factors.iter().map(|f| f.weight().to_bits()).collect();
seen.sort_unstable();
seen.dedup();
if seen.len() > 1 {
out.push(Unsupported::NonUniformCouplings { distinct: seen.len() });
}
}
if let Some(bits) = self.coupling_bits {
let err = Self::quantization_error(p, bits);
if err > 1e-3 {
out.push(Unsupported::CouplingPrecision { bits, worst_relative_error: err });
}
}
out
}
pub fn quantization_error(p: &Program, bits: u32) -> f64 {
let max = p.factors.iter().map(|f| f.weight().abs()).fold(0.0f64, f64::max);
if max == 0.0 || bits == 0 {
return 0.0;
}
let levels = ((1u64 << (bits - 1)) - 1) as f64;
let step = max / levels;
p.factors
.iter()
.map(|f| {
let w = f.weight();
if w == 0.0 {
0.0
} else {
((w / step).round() * step - w).abs() / w.abs()
}
})
.fold(0.0f64, f64::max)
}
pub fn requantize(&self, p: &mut Program) -> f64 {
let Some(bits) = self.coupling_bits else { return 0.0 };
let err = Self::quantization_error(p, bits);
let max = p.factors.iter().map(|f| f.weight().abs()).fold(0.0f64, f64::max);
if max == 0.0 || bits == 0 {
return 0.0;
}
let levels = ((1u64 << (bits - 1)) - 1) as f64;
let step = max / levels;
for f in &mut p.factors {
let vars: Vec<usize> = f.vars().collect();
let w = (f.weight() / step).round() * step;
*f = crate::factor::Factor::new(&vars, w, p.spins).expect("requantised in place");
}
err
}
}
pub trait Device {
fn fabric(&self) -> Fabric;
fn program(&mut self, p: &Program) -> Vec<Unsupported>;
fn run(&mut self, schedule: &crate::schedule::Schedule, seed: u64) -> Result<Vec<i8>, String>;
fn ledger(&self) -> crate::ledger::Ledger;
}
pub struct Cpu {
graph: Option<crate::graph::Graph>,
state: Vec<i8>,
ledger: crate::ledger::Ledger,
}
impl Default for Cpu {
fn default() -> Self {
Cpu { graph: None, state: Vec::new(), ledger: crate::ledger::Ledger::default() }
}
}
impl Device for Cpu {
fn fabric(&self) -> Fabric {
Fabric::unconstrained("cpu", crate::ledger::Z1_SPICE)
}
fn program(&mut self, p: &Program) -> Vec<Unsupported> {
let bad = self.fabric().check(p);
if bad.is_empty() {
match p.to_graph() {
Ok(g) => {
self.state = vec![-1; g.n];
self.graph = Some(g);
}
Err(_) => return vec![Unsupported::ArityTooHigh { arity: 3, limit: 2 }],
}
}
bad
}
fn run(&mut self, schedule: &crate::schedule::Schedule, seed: u64) -> Result<Vec<i8>, String> {
let g = self.graph.as_ref().ok_or("no program loaded")?;
let (best, _) = crate::tempering::anneal_scheduled(g, schedule, seed, Some(&mut self.ledger));
self.state = best.clone();
Ok(best)
}
fn ledger(&self) -> crate::ledger::Ledger {
self.ledger
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ledger::Z1_SPICE;
use crate::schedule::Schedule;
fn prog(src: &str) -> Program {
Program::from_ftp(src).unwrap()
}
fn constrained() -> Fabric {
Fabric {
name: "test-fabric",
topology: Topology::Degree(4),
max_spins: Some(64),
max_degree: Some(4),
coupling_bits: Some(8),
field_bits: Some(8),
supports_field: false,
max_arity: 2,
uniform_couplings: false,
prices: Z1_SPICE,
}
}
#[test]
fn a_simulator_accepts_anything() {
let p = prog("ftp 1\nspins 5\nfactor 1 0 1 2 3 4\nbias 0 0.5\n");
assert!(Fabric::unconstrained("sim", Z1_SPICE).check(&p).is_empty());
}
#[test]
fn every_limit_is_reported_and_names_itself() {
let mut src = String::from("ftp 1\nspins 100\n");
for j in 1..=8 {
src.push_str(&format!("factor 1 0 {j}\n")); }
src.push_str("factor 1 10 11 12\n"); src.push_str("bias 5 0.5\n"); let bad = constrained().check(&prog(&src));
assert!(bad.iter().any(|u| matches!(u, Unsupported::TooManySpins { .. })));
assert!(bad.iter().any(|u| matches!(u, Unsupported::TooHighDegree { .. })));
assert!(bad.iter().any(|u| matches!(u, Unsupported::ArityTooHigh { .. })));
assert!(bad.iter().any(|u| matches!(u, Unsupported::NoFieldSupport { .. })));
assert_eq!(bad.len(), 4, "every violation at once, not just the first: {bad:?}");
let text = bad.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(" | ");
assert!(text.contains("sparsify"), "the degree error should suggest a fix: {text}");
assert!(text.contains("pairwise"), "the arity error should suggest a fix: {text}");
}
#[test]
fn int8_precision_is_caught_before_it_changes_the_answer() {
let p = prog("ftp 1\nspins 3\nfactor 1000 0 1\nfactor 0.5 1 2\n");
let bad = constrained().check(&p);
let prec = bad.iter().find(|u| matches!(u, Unsupported::CouplingPrecision { .. }));
assert!(prec.is_some(), "a 2000:1 range in 8 bits must be refused: {bad:?}");
assert!(prec.unwrap().to_string().contains("requantize"));
}
#[test]
fn a_narrow_range_survives_int8_and_is_not_refused() {
let p = prog("ftp 1\nspins 4\nfactor 1 0 1\nfactor -1 1 2\nfactor 1 2 3\n");
assert!(!constrained()
.check(&p)
.iter()
.any(|u| matches!(u, Unsupported::CouplingPrecision { .. })));
}
#[test]
fn requantizing_reports_the_damage_it_did() {
let mut p = prog("ftp 1\nspins 3\nfactor 1000 0 1\nfactor 0.5 1 2\n");
let before: Vec<f64> = p.factors.iter().map(|f| f.weight()).collect();
let err = constrained().requantize(&mut p);
let after: Vec<f64> = p.factors.iter().map(|f| f.weight()).collect();
assert!(err > 1e-3, "it should admit a real loss, got {err}");
assert_ne!(before, after, "and it should actually have changed the weights");
assert!(!constrained()
.check(&p)
.iter()
.any(|u| matches!(u, Unsupported::CouplingPrecision { .. })));
}
#[test]
fn the_cpu_backend_runs_a_program_through_the_trait() {
let mut d = Cpu::default();
let p = prog("ftp 1\nspins 5\nfactor -1 0 1\nfactor -1 1 2\nfactor -1 2 3\n\
factor -1 3 4\nfactor -1 4 0\n");
assert!(d.program(&p).is_empty());
let s = d.run(&Schedule::geometric(0.05, 6.0, 60, 40), 1).unwrap();
assert_eq!(s.len(), 5);
let g = p.to_graph().unwrap();
assert_eq!(g.energy(&s), -3.0, "the frustrated 5-cycle optimum, through the Device seam");
assert!(d.ledger().samples > 0, "the ledger must be charged");
}
#[test]
fn a_backend_that_cannot_run_it_says_so_before_running() {
let mut d = Cpu::default();
let p = prog("ftp 1\nspins 4\nfactor 1 0 1 2\n");
let bad = d.program(&p);
assert!(!bad.is_empty(), "a program it cannot lower must be refused up front");
assert!(d.run(&Schedule::constant(1.0, 10), 1).is_err());
}
}