use crate::graph::Graph;
use crate::ledger::Ledger;
use crate::rng::Pcg;
#[derive(Clone, Debug)]
pub struct Params {
pub iterations: usize,
pub tenure: usize,
pub restart_after: Option<usize>,
pub start: Option<Vec<i8>>,
}
impl Default for Params {
fn default() -> Self {
Params { iterations: 50_000, tenure: 0, restart_after: Some(5_000), start: None }
}
}
#[derive(Clone, Debug)]
pub struct Outcome {
pub state: Vec<i8>,
pub energy: f64,
pub found_at: usize,
pub restarts: usize,
pub iterations_run: usize,
}
pub fn search(g: &Graph, p: &Params, seed: u64) -> Outcome {
search_metered(g, p, seed, None)
}
pub fn search_metered(g: &Graph, p: &Params, seed: u64, mut ledger: Option<&mut Ledger>) -> Outcome {
let n = g.n;
if n == 0 {
return Outcome { state: Vec::new(), energy: 0.0, found_at: 0, restarts: 0, iterations_run: 0 };
}
let tenure = if p.tenure == 0 { (n / 10).max(10) } else { p.tenure };
let tenure = tenure.min(n.saturating_sub(1)).max(1);
let mut rng = Pcg::new(seed, 0x7AB0);
let mut s: Vec<i8> = match &p.start {
Some(st) if st.len() == n => st.clone(),
_ => (0..n).map(|_| rng.spin(0.5)).collect(),
};
let mut delta = gains(g, &s);
let mut energy = g.energy(&s);
let mut best = s.clone();
let mut best_e = energy;
let mut found_at = 0usize;
let mut restarts = 0usize;
let mut last_used = vec![usize::MAX; n];
let mut since_improve = 0usize;
let mut ran = 0usize;
for iter in 0..p.iterations {
ran = iter + 1;
if let Some(l) = ledger.as_deref_mut() {
l.samples += n as u64;
}
let mut pick = usize::MAX;
let mut pick_d = f64::INFINITY;
for i in 0..n {
let d = delta[i];
let is_tabu = last_used[i] != usize::MAX && iter <= last_used[i].saturating_add(tenure);
let aspires = energy + d < best_e - 1e-12;
if (!is_tabu || aspires) && d < pick_d {
pick_d = d;
pick = i;
}
}
if pick == usize::MAX {
s = (0..n).map(|_| rng.spin(0.5)).collect();
delta = gains(g, &s);
energy = g.energy(&s);
last_used.iter_mut().for_each(|v| *v = usize::MAX);
since_improve = 0;
restarts += 1;
continue;
}
flip(g, &mut s, &mut delta, pick);
energy += pick_d;
last_used[pick] = iter;
if energy < best_e - 1e-12 {
best_e = energy;
best.copy_from_slice(&s);
found_at = iter;
since_improve = 0;
} else {
since_improve += 1;
}
if let Some(after) = p.restart_after {
if since_improve >= after {
s = (0..n).map(|_| rng.spin(0.5)).collect();
delta = gains(g, &s);
energy = g.energy(&s);
last_used.iter_mut().for_each(|v| *v = usize::MAX);
since_improve = 0;
restarts += 1;
}
}
}
let energy = g.energy(&best);
Outcome { state: best, energy, found_at, restarts, iterations_run: ran }
}
pub(crate) fn gains(g: &Graph, s: &[i8]) -> Vec<f64> {
(0..g.n)
.map(|i| {
let mut field = g.h[i];
for k in g.offset[i]..g.offset[i + 1] {
field += g.w[k] * s[g.nbr[k] as usize] as f64;
}
2.0 * s[i] as f64 * field
})
.collect()
}
pub(crate) fn flip(g: &Graph, s: &mut [i8], delta: &mut [f64], i: usize) {
s[i] = -s[i];
delta[i] = -delta[i];
let si = s[i] as f64;
for k in g.offset[i]..g.offset[i + 1] {
let j = g.nbr[k] as usize;
delta[j] += 4.0 * g.w[k] * si * s[j] as f64;
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_handed_state_is_where_the_search_starts_and_is_never_lost() {
let g = crate::ising::lattice2d(8, 1.0);
let optimum = vec![1i8; g.n];
let e_opt = g.energy(&optimum);
let p = Params {
iterations: 500,
tenure: 0,
restart_after: None,
start: Some(optimum.clone()),
};
let r = search(&g, &p, 5);
assert!(r.energy <= e_opt + 1e-9, "handed the optimum, returned {} vs {e_opt}", r.energy);
assert_eq!(r.found_at, 0, "the handed state IS the best, found before any flip");
let bad = Params { start: Some(vec![1i8; g.n + 3]), ..p.clone() };
let r2 = search(&g, &bad, 5);
assert!(r2.energy.is_finite());
assert_eq!(r2.state.len(), g.n);
}
#[test]
fn a_warm_start_is_a_different_run_from_a_cold_one() {
let mut rng = Pcg::new(3, 1);
let mut b = crate::graph::GraphBuilder::new(64);
for i in 0..64usize {
b.couple(i, (i + 1) % 64, if rng.f64() < 0.5 { 1.0 } else { -1.0 });
b.couple(i, (i + 7) % 64, if rng.f64() < 0.5 { 1.0 } else { -1.0 });
}
let g = b.build();
let warm: Vec<i8> = (0..g.n).map(|i| if i % 3 == 0 { 1 } else { -1 }).collect();
let p = Params { iterations: 300, tenure: 4, restart_after: None, start: None };
let cold = search(&g, &p, 9);
let hot = search(&g, &Params { start: Some(warm), ..p.clone() }, 9);
assert!(
cold.state != hot.state || (cold.energy - hot.energy).abs() > 1e-12,
"same seed and a different start must be a different run, or `start` does nothing"
);
}
use super::*;
use crate::graph::GraphBuilder;
use crate::ising::lattice2d;
fn random_graph(n: usize, p: f64, seed: u64) -> Graph {
let mut rng = Pcg::new(seed, 0xC0);
let mut gb = GraphBuilder::new(n);
for i in 0..n {
gb.bias(i, rng.f64() * 2.0 - 1.0);
for j in (i + 1)..n {
if rng.f64() < p {
gb.couple(i, j, rng.f64() * 2.0 - 1.0);
}
}
}
gb.build()
}
fn brute_min(g: &Graph) -> f64 {
(0..(1u32 << g.n))
.map(|m| {
let s: Vec<i8> = (0..g.n).map(|i| if m >> i & 1 == 1 { 1 } else { -1 }).collect();
g.energy(&s)
})
.fold(f64::INFINITY, f64::min)
}
#[test]
fn the_incremental_gain_matches_a_full_recomputation() {
let g = random_graph(12, 0.4, 3);
let mut rng = Pcg::new(9, 0);
let mut s: Vec<i8> = (0..g.n).map(|_| rng.spin(0.5)).collect();
let mut delta = gains(&g, &s);
for _ in 0..200 {
let i = (rng.f64() * g.n as f64) as usize % g.n;
flip(&g, &mut s, &mut delta, i);
let fresh = gains(&g, &s);
for k in 0..g.n {
assert!(
(delta[k] - fresh[k]).abs() < 1e-9,
"gain {k} drifted: incremental {} vs recomputed {}",
delta[k],
fresh[k]
);
}
}
}
#[test]
fn a_flip_changes_the_energy_by_exactly_its_gain() {
let g = random_graph(10, 0.5, 11);
let mut rng = Pcg::new(4, 0);
let mut s: Vec<i8> = (0..g.n).map(|_| rng.spin(0.5)).collect();
let mut delta = gains(&g, &s);
for _ in 0..100 {
let i = (rng.f64() * g.n as f64) as usize % g.n;
let before = g.energy(&s);
let predicted = delta[i];
flip(&g, &mut s, &mut delta, i);
let after = g.energy(&s);
assert!((after - before - predicted).abs() < 1e-9, "predicted {predicted}, got {}", after - before);
}
}
#[test]
fn the_reported_energy_belongs_to_the_reported_state() {
for seed in 0..20u64 {
let g = random_graph(14, 0.35, seed);
let r = search(
&g,
&Params { iterations: 3_000, tenure: 0, restart_after: Some(400), start: None },
seed,
);
assert!((r.energy - g.energy(&r.state)).abs() < 1e-9, "seed {seed}");
}
}
fn steepest_descent(g: &Graph, seed: u64) -> f64 {
let mut rng = Pcg::new(seed, 0x7AB0);
let mut s: Vec<i8> = (0..g.n).map(|_| rng.spin(0.5)).collect();
let mut delta = gains(g, &s);
loop {
let mut pick = usize::MAX;
let mut best = -1e-12;
for i in 0..g.n {
if delta[i] < best {
best = delta[i];
pick = i;
}
}
if pick == usize::MAX {
return g.energy(&s); }
flip(g, &mut s, &mut delta, pick);
}
}
#[test]
fn tabu_escapes_the_local_minima_that_steepest_descent_stops_in() {
let mut wins = 0;
let mut losses = 0;
for seed in 0..30u64 {
let g = random_graph(16, 0.4, seed + 100);
let descent = steepest_descent(&g, seed);
let tabu = search(&g, &Params { iterations: 2_000, tenure: 0, restart_after: None, start: None }, seed);
if tabu.energy < descent - 1e-9 {
wins += 1;
} else if tabu.energy > descent + 1e-9 {
losses += 1;
}
}
assert!(wins >= 20, "tabu improved on descent in only {wins}/30 instances");
assert_eq!(losses, 0, "tabu was WORSE than plain descent on {losses}/30, which it cannot be \
if it is tracking the best state it ever saw");
}
#[test]
fn a_tenure_larger_than_the_graph_spends_its_whole_budget() {
for n in [3usize, 4, 6, 8, 9, 10, 11, 40] {
let g = random_graph(n, 0.5, n as u64);
for tenure in [50usize, 0, 1] {
let p = Params { iterations: 3_000, tenure, restart_after: None, start: None };
let r = search(&g, &p, 1);
assert_eq!(
r.iterations_run, p.iterations,
"n={n} tenure={tenure}: ran {} of {} iterations",
r.iterations_run, p.iterations
);
}
}
}
#[test]
fn small_graphs_still_reach_their_optimum_with_the_default_shape() {
for n in [4usize, 6, 8, 9, 10] {
let g = random_graph(n, 0.5, n as u64 + 7);
let truth = brute_min(&g);
let r = search(&g, &Params { iterations: 20_000, tenure: 0, restart_after: Some(200), start: None }, 3);
assert!(
r.energy <= truth + 1e-9,
"n={n}: got {} against a true minimum of {truth} after {} iterations",
r.energy, r.iterations_run
);
}
}
#[test]
fn it_finds_the_true_optimum_on_instances_small_enough_to_enumerate() {
for seed in 0..25u64 {
let g = random_graph(12, 0.45, seed + 500);
let truth = brute_min(&g);
let r = search(&g, &Params { iterations: 20_000, tenure: 0, restart_after: Some(500), start: None }, seed);
assert!(
r.energy <= truth + 1e-9,
"seed {seed}: found {} against a true minimum of {truth}",
r.energy
);
}
}
#[test]
fn the_ferromagnet_reaches_its_ground_state() {
let g = lattice2d(8, 1.0);
let r = search(&g, &Params { iterations: 20_000, tenure: 0, restart_after: Some(2_000), start: None }, 7);
assert!((r.energy - (-128.0)).abs() < 1e-9, "got {}", r.energy);
}
#[test]
fn the_ledger_charges_a_move_evaluation_per_node_per_iteration() {
let g = lattice2d(6, 1.0);
let mut led = Ledger::default();
let iters = 100;
search_metered(&g, &Params { iterations: iters, tenure: 4, restart_after: None, start: None }, 1, Some(&mut led));
assert_eq!(led.samples, (g.n * iters) as u64);
}
#[test]
fn an_empty_graph_returns_rather_than_panicking() {
let g = GraphBuilder::new(0).build();
let r = search(&g, &Params::default(), 1);
assert!(r.state.is_empty() && r.energy == 0.0);
}
}