hubo_vs_reduction/hubo_vs_reduction.rs
1// What does quadratising a higher-order model actually cost?
2//
3// `src/hubo.rs` says, in its module doc, that `Hubo::from_graph` "exists so the two paths can be
4// run against each other on the same model rather than argued about". Nothing ran them against each
5// other. This does.
6//
7// The two paths, on the SAME terms:
8//
9// native `src/hubo.rs`. No ancillas at all. A term of any width contributes -w * prod(s_i) and
10// the change from one flip is a sum over the terms containing that spin.
11// reduced Rosenberg's reduction (`src/reduce.rs`). Introduce an ancilla per substituted pair,
12// penalise it into agreeing with the product, anneal the pairwise graph, slice the
13// answer back down to the original spins. This is the path every non-Rust surface is on
14// today, because `hubo` has no C ABI.
15//
16// GIVING THE OTHER ARM ITS BEST SHOT, which is where the first version of this went wrong. Run at
17// the native model's beta ladder the reduced arm scored -11.50 against -26.88, and the table read
18// as a rout. It was measuring the ladder. The reduction's penalty is the sum of every coefficient's
19// magnitude -- 1308 on a model whose terms are all +/-1 -- so the reduced graph's energy scale is a
20// thousand times the objective's, and a ladder that suits the objective leaves the penalty terms
21// frozen before the search has begun. Sweeping the ladder's cold end from 5e-2 down to 5e-6 moved
22// the reduced arm from -11.50 to -16.62. The ladder used here is the best of that sweep, so the
23// comparison is against the reduction at its best rather than at its worst.
24//
25// AND ITS BEST SHOT AT BUDGET TOO. Both arms are judged on the ORIGINAL higher-order model: a
26// reduced run is scored on the model it was asked about, never on the one it was lowered to. The
27// reduced arm is then given 1x, 4x, 16x, 64x, 256x and 1024x the native arm's sweep budget, because
28// "costs more compute" and "does not get there" are different findings and only a budget ladder
29// separates them. An earlier shootout in this repository gave one arm 500 flips and the other
30// 320,000 and read as a clean sweep for the arm we wrote.
31//
32// THE ANCILLA CHECK. `src/reduce.rs` guarantees that the reduced energy minimised over the ancillas
33// equals the original -- a statement about ground states. It says outright that the penalty "makes
34// violating assignments expensive rather than impossible", so a reduced run may return a state
35// whose ancillas do not hold, and that state's projection is then not an answer to the original
36// model at all. That is counted exactly, by comparing the reduced energy plus the dropped offset
37// against the original model's energy of the projection: equal to floating point when every ancilla
38// held, short by at least one penalty when one did not. It stays at zero here, and that is the
39// confirmation rather than a null result -- the reduced arm is stuck INSIDE the feasible region,
40// not wandering out of it.
41//
42// NOT run in CI: the 1024x budget column takes minutes, and shortening it to fit would delete the
43// finding rather than the runtime.
44//
45// run: cargo run --release --example hubo_vs_reduction
46
47use ferrotherm::ftp::Program;
48use ferrotherm::hubo::{self, Hubo};
49use ferrotherm::reduce;
50use ferrotherm::rng::Pcg;
51use ferrotherm::tempering::{self, geometric_ladder};
52
53/// A random k-body instance: `t` terms of arity `k` over `n` spins, weights in {-1, +1}.
54///
55/// The terms are generated ONCE and both paths are built from this same list, so the comparison
56/// cannot drift into comparing two instances. Distinct variables within a term, because
57/// `src/factor.rs` refuses a repeated one -- `s·s = 1` would silently make the term a different
58/// order than the one written.
59fn instance(n: usize, k: usize, t: usize, seed: u64) -> Vec<(Vec<usize>, f64)> {
60 let mut rng = Pcg::new(seed, 0x000B_11C0);
61 let mut out = Vec::with_capacity(t);
62 while out.len() < t {
63 let mut vs: Vec<usize> = Vec::with_capacity(k);
64 while vs.len() < k {
65 let v = (rng.f64() * n as f64) as usize % n;
66 if !vs.contains(&v) {
67 vs.push(v);
68 }
69 }
70 // Two terms over the same variables are allowed and simply add their weights; avoiding
71 // them would bias the instance family for no reason the model cares about.
72 let w = if rng.f64() < 0.5 { 1.0 } else { -1.0 };
73 out.push((vs, w));
74 }
75 out
76}
77
78fn hubo_of(terms: &[(Vec<usize>, f64)], n: usize) -> Hubo {
79 let mut h = Hubo::new(n);
80 for (vs, w) in terms {
81 h.add(vs, *w).expect("distinct in-range variables");
82 }
83 h
84}
85
86/// The same terms as an `.ftp` program, which is what `reduce::to_pairwise` takes.
87fn ftp_of(terms: &[(Vec<usize>, f64)], n: usize) -> String {
88 let mut s = format!("ftp 1\nspins {n}\n");
89 for (vs, w) in terms {
90 s.push_str(&format!("factor {w}"));
91 for v in vs {
92 s.push_str(&format!(" {v}"));
93 }
94 s.push('\n');
95 }
96 s
97}
98
99/// The native arm's budget. The reduced arm is given multiples of it.
100const STAGES: usize = 200;
101const SWEEPS: usize = 8;
102const SEEDS: u64 = 16;
103
104/// The cold end of the reduced arm's ladder, chosen by sweeping it: at 5e-2 the reduced arm scores
105/// -11.50 on the first case and at 5e-5 it scores -16.62, because the penalty terms are three orders
106/// of magnitude above the objective's scale and a ladder suited to the objective never melts them.
107const REDUCED_BETA_MIN: f64 = 5e-5;
108
109fn main() {
110 let cases: [(usize, usize, usize); 4] = [(24, 3, 32), (32, 3, 48), (24, 4, 24), (40, 3, 60)];
111 let budgets: [usize; 6] = [1, 4, 16, 64, 256, 1024];
112
113 println!("hubo native vs Rosenberg reduction, on the same terms");
114 println!(
115 "mean best energy of the ORIGINAL model over {SEEDS} seeds; lower is better.\n\
116 The native arm runs once, at 1x. Every reduced column is a MULTIPLE of that same budget.\n"
117 );
118
119 print!(
120 "{:>4} {:>2} {:>4} {:>6} {:>4} {:>7} {:>8} ",
121 "n", "k", "trm", "spins", "anc", "pen/w", "native"
122 );
123 for b in budgets {
124 print!("{:>9}", format!("red {b}x"));
125 }
126 println!(" broken");
127
128 for (n, k, t) in cases {
129 let mut native = 0.0f64;
130 let mut reduced = [0.0f64; 6];
131 let mut broken = 0usize;
132 let (mut ancillas, mut rspins, mut penalty) = (0usize, 0usize, 0.0f64);
133
134 for seed in 0..SEEDS {
135 let terms = instance(n, k, t, seed);
136 let h = hubo_of(&terms, n);
137 let prog = Program::from_ftp(&ftp_of(&terms, n)).expect("a well-formed program");
138 let red = reduce::to_pairwise(&prog).expect("a reducible program");
139 let g = red.program.to_graph().expect("a pairwise graph");
140 ancillas = red.ancillas;
141 rspins = red.program.spins;
142 penalty = red.penalty;
143
144 let p = hubo::Params {
145 beta_min: 0.05,
146 beta_max: 8.0,
147 stages: STAGES,
148 sweeps_per_stage: SWEEPS,
149 };
150 native += hubo::anneal(&h, &p, seed).energy;
151
152 let ladder = geometric_ladder(REDUCED_BETA_MIN, 8.0, STAGES);
153 for (i, mult) in budgets.iter().enumerate() {
154 let sched: Vec<(f64, usize)> =
155 ladder.iter().map(|&b| (b, SWEEPS * mult)).collect();
156 let (state, reduced_e) = tempering::anneal(&g, &sched, seed, None);
157 let original_e = h.energy(&state[..n]);
158 reduced[i] += original_e;
159 // Comparing the two energies IS the ancilla check, and it needs no knowledge of
160 // which spins are ancillas or of how they were defined.
161 if ((reduced_e + red.offset) - original_e).abs() > 1e-6 {
162 broken += 1;
163 }
164 }
165 }
166
167 let m = SEEDS as f64;
168 print!(
169 "{n:>4} {k:>2} {t:>4} {rspins:>6} {ancillas:>4} {:>7.0} {:>8.2} ",
170 penalty,
171 native / m
172 );
173 for r in reduced {
174 print!("{:>9.2}", r / m);
175 }
176 println!(" {:>3}/{}", broken, SEEDS as usize * budgets.len());
177 }
178
179 println!(
180 "\n'anc' is the ancillas the reduction added and 'spins' the graph it had to search: each \
181 one is a variable\nthe answer depends on and the question never mentioned. The native path \
182 adds none.\n\n\
183 'pen/w' is the penalty the reduction chose, against term weights of 1. That ratio is the \
184 mechanism: any\nsingle flip that would move the search must first pay it, so the landscape \
185 is rigid and a single-flip\nsampler cannot traverse it. 'broken' counts runs whose ancillas \
186 did not hold, and it stays at zero --\nwhich is the confirmation, not a null result: the \
187 reduced arm is stuck inside the feasible region\nrather than wandering out of it.\n\n\
188 The budget columns are the finding. If the reduced arm caught up at 64x or 256x, the cost \
189 of quadratising\nwould be compute, and compute is buyable."
190 );
191}