Skip to main content

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}