use crate::graph::Graph;
use crate::ledger::Ledger;
use crate::rng::Pcg;
use crate::tabu::{flip, gains};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Params {
pub iterations: usize,
pub l0: Option<usize>,
pub t: usize,
pub p0: f64,
pub q: f64,
pub tenure: Option<(usize, usize)>,
pub random_after_improvement: bool,
}
impl Default for Params {
fn default() -> Self {
Params {
iterations: 50_000,
l0: None,
t: 1_000,
p0: 0.8,
q: 0.5,
tenure: None,
random_after_improvement: true,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Perturbations {
pub directed_one: usize,
pub directed_two: usize,
pub random: usize,
}
#[derive(Clone, Debug)]
pub struct Outcome {
pub state: Vec<i8>,
pub energy: f64,
pub descents: usize,
pub iterations_run: usize,
pub perturbations: Perturbations,
pub max_jump: usize,
pub returns: 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,
descents: 0,
iterations_run: 0,
perturbations: Perturbations::default(),
max_jump: 0,
returns: 0,
};
}
let mut rng = Pcg::new(seed, 0x000B_155E);
let mut s: Vec<i8> = (0..n).map(|_| rng.spin(0.5)).collect();
let mut delta = gains(g, &s);
let mut energy = g.energy(&s);
let l0 = p.l0.unwrap_or_else(|| ((n as f64 * 0.01).round() as usize).max(1)).clamp(1, n);
let (t_lo, t_hi) = p.tenure.unwrap_or((3, (n / 10).max(4)));
let (t_lo, t_hi) = (t_lo.max(1), t_hi.max(t_lo + 1));
let t = p.t.max(1);
let mut best = s.clone();
let mut best_e = energy;
let mut prev_optimum = s.clone();
let mut tabu_until = vec![usize::MAX; n];
let mut iter = 0usize;
let (mut omega, mut jump) = (0usize, l0);
let mut out = Outcome {
state: Vec::new(),
energy: 0.0,
descents: 0,
iterations_run: 0,
perturbations: Perturbations::default(),
max_jump: l0,
returns: 0,
};
while iter < p.iterations {
loop {
if iter >= p.iterations {
break;
}
let mut pick = usize::MAX;
let mut low = -1e-12; for i in 0..n {
if delta[i] < low {
low = delta[i];
pick = i;
}
}
if pick == usize::MAX {
break;
}
energy += delta[pick];
flip(g, &mut s, &mut delta, pick);
tabu_until[pick] = iter + tenure(&mut rng, t_lo, t_hi);
iter += 1;
if let Some(l) = ledger.as_deref_mut() {
l.samples += n as u64;
}
}
out.descents += 1;
if energy < best_e - 1e-12 {
best_e = energy;
best.copy_from_slice(&s);
omega = 0;
} else {
omega += 1;
}
if omega > t {
omega = 0; }
if s == prev_optimum {
jump += 1;
out.returns += 1;
} else {
jump = l0;
}
jump = jump.min(n);
out.max_jump = out.max_jump.max(jump);
prev_optimum.copy_from_slice(&s);
if iter >= p.iterations {
break;
}
let kind = if omega == 0 && p.random_after_improvement {
Kind::Random
} else {
let prob = (-(omega as f64) / t as f64).exp().max(p.p0);
Kind::choose(prob, p.q, &mut rng)
};
match kind {
Kind::DirectedOne => out.perturbations.directed_one += 1,
Kind::DirectedTwo => out.perturbations.directed_two += 1,
Kind::Random => out.perturbations.random += 1,
}
for _ in 0..jump {
if iter >= p.iterations {
break;
}
let moves = match kind {
Kind::Random => [Some((rng.next_u32() as usize) % n), None],
Kind::DirectedOne => [pick_eligible(&delta, &tabu_until, iter, energy, best_e, &s, None), None],
Kind::DirectedTwo => [
pick_eligible(&delta, &tabu_until, iter, energy, best_e, &s, Some(1)),
pick_eligible(&delta, &tabu_until, iter, energy, best_e, &s, Some(-1)),
],
};
for m in moves.into_iter().flatten() {
if iter >= p.iterations {
break;
}
energy += delta[m];
flip(g, &mut s, &mut delta, m);
tabu_until[m] = iter + tenure(&mut rng, t_lo, t_hi);
iter += 1;
if let Some(l) = ledger.as_deref_mut() {
l.samples += n as u64;
}
if energy < best_e - 1e-12 {
best_e = energy;
best.copy_from_slice(&s);
}
}
}
}
out.iterations_run = iter;
out.energy = g.energy(&best);
out.state = best;
out
}
fn tenure(rng: &mut Pcg, lo: usize, hi: usize) -> usize {
lo + (rng.next_u32() as usize) % (hi - lo + 1)
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
DirectedOne,
DirectedTwo,
Random,
}
impl Kind {
fn choose(p: f64, q: f64, rng: &mut Pcg) -> Kind {
if rng.f64() < p {
if rng.f64() < q {
Kind::DirectedOne
} else {
Kind::DirectedTwo
}
} else {
Kind::Random
}
}
}
fn pick_eligible(
delta: &[f64],
tabu_until: &[usize],
iter: usize,
energy: f64,
best: f64,
s: &[i8],
side: Option<i8>,
) -> Option<usize> {
let mut pick = usize::MAX;
let mut low = f64::INFINITY;
for i in 0..delta.len() {
if let Some(v) = side {
if s[i] != v {
continue;
}
}
let free = tabu_until[i] == usize::MAX || iter > tabu_until[i];
let aspires = energy + delta[i] < best - 1e-12;
if (free || aspires) && delta[i] < low {
low = delta[i];
pick = i;
}
}
(pick != usize::MAX).then_some(pick)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::GraphBuilder;
use crate::ising::lattice2d;
fn frustrated(n: usize, p: f64, seed: u64) -> Graph {
let mut rng = Pcg::new(seed, 0x000B_15C0);
let mut gb = GraphBuilder::new(n);
for i in 0..n {
for j in (i + 1)..n {
if rng.f64() < p {
gb.couple(i, j, rng.f64() * 2.0 - 1.0);
}
}
}
gb.build()
}
fn steepest_descent(g: &Graph, seed: u64) -> f64 {
let mut rng = Pcg::new(seed, 0x000B_155E);
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 low = -1e-12;
for i in 0..g.n {
if delta[i] < low {
low = delta[i];
pick = i;
}
}
if pick == usize::MAX {
return g.energy(&s);
}
flip(g, &mut s, &mut delta, pick);
}
}
#[test]
fn the_energy_returned_is_the_energy_of_the_state_returned() {
for seed in 0..8u64 {
let g = frustrated(40, 0.3, seed);
let r = search(&g, &Params { iterations: 4_000, ..Params::default() }, seed);
assert_eq!(r.state.len(), g.n);
assert!(r.state.iter().all(|&v| v == 1 || v == -1));
assert!(
(r.energy - g.energy(&r.state)).abs() < 1e-9,
"seed {seed}: reported {}, the state gives {}",
r.energy,
g.energy(&r.state)
);
}
}
#[test]
fn it_escapes_the_local_minima_that_steepest_descent_stops_in() {
let (mut wins, mut losses) = (0, 0);
for seed in 0..30u64 {
let g = frustrated(30, 0.35, seed + 500);
let d = steepest_descent(&g, seed);
let b = search(&g, &Params { iterations: 3_000, ..Params::default() }, seed);
if b.energy < d - 1e-9 {
wins += 1;
} else if b.energy > d + 1e-9 {
losses += 1;
}
}
assert_eq!(losses, 0, "BLS ended above a plain descent on {losses} of 30 instances");
assert!(wins >= 20, "only beat descent on {wins} of 30; the perturbation is not working");
}
#[test]
fn the_perturbation_mix_is_genuinely_mixed() {
let g = frustrated(60, 0.25, 3);
let r = search(&g, &Params { iterations: 20_000, ..Params::default() }, 3);
let p = r.perturbations;
assert!(p.random > 0, "no random perturbation fired");
assert!(p.directed_one > 0, "no M1 perturbation fired");
assert!(p.directed_two > 0, "no M2 perturbation fired");
assert!(r.descents > 10, "only {} descents in 20k moves", r.descents);
}
#[test]
fn the_jump_grows_exactly_when_the_search_comes_back() {
let g = frustrated(50, 0.3, 11);
let r = search(&g, &Params { iterations: 30_000, ..Params::default() }, 11);
let l0 = ((50.0f64 * 0.01).round() as usize).max(1);
assert!(r.max_jump >= l0);
assert_eq!(
r.max_jump > l0,
r.returns > 0,
"the jump grew {} times against {} returns to the previous optimum",
r.max_jump - l0,
r.returns
);
}
#[test]
fn the_two_readings_of_the_pseudo_code_are_different_searches() {
let g = frustrated(60, 0.25, 21);
let faithful = search(&g, &Params { iterations: 20_000, ..Params::default() }, 21);
let intended = search(
&g,
&Params { iterations: 20_000, random_after_improvement: false, ..Params::default() },
21,
);
assert_ne!(
faithful.perturbations, intended.perturbations,
"both readings produced the same perturbation mix, so the parameter does nothing"
);
for (name, r) in [("faithful", &faithful), ("intended", &intended)] {
assert!((r.energy - g.energy(&r.state)).abs() < 1e-9, "{name}");
}
}
#[test]
fn the_budget_is_a_ceiling_on_every_instance_not_just_the_convenient_one() {
for budget in [999, 5_000, 20_001] {
for seed in 0..6u64 {
let g = frustrated(24, 0.35, seed);
let r = search(&g, &Params { iterations: budget, ..Params::default() }, seed);
assert_eq!(
r.iterations_run, budget,
"budget {budget}, seed {seed}: ran {} flips",
r.iterations_run
);
}
}
let g = lattice2d(6, 1.0);
let r = search(&g, &Params { iterations: 5_000, ..Params::default() }, 2);
assert!((r.energy + 2.0 * 36.0).abs() < 1e-9, "energy {}", r.energy);
}
#[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 && r.iterations_run == 0);
}
#[test]
fn the_ledger_charges_a_move_evaluation_per_node_per_move() {
let g = frustrated(20, 0.4, 5);
let mut led = Ledger::default();
let r = search_metered(&g, &Params { iterations: 1_000, ..Params::default() }, 5, Some(&mut led));
assert_eq!(led.samples, (r.iterations_run * g.n) as u64);
}
}