1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
//! G-set: the standard max-cut benchmark, and the sign convention that decides everything.
//!
//! G-set (Helmberg and Rendl) is what max-cut solvers have been compared on for twenty-five years.
//! Every paper reports a **best cut found**, and the field's league table is a list of those — which
//! says who has looked hardest, not how good the answer is. With [`bound`](crate::bound) the same
//! run can report an **upper bound on the max cut** and therefore a gap, which is a statement about
//! the instance rather than about the community.
//!
//! # Max-cut is an Ising minimisation with the couplings NEGATED
//!
//! For a cut `S`, with `s_i = +1` exactly when `i ∈ S`:
//!
//! ```text
//! cut(s) = ½ Σ_ij w_ij (1 − s_i s_j) = ½ [ W − Σ_ij w_ij s_i s_j ], W = Σ_ij w_ij
//! ```
//!
//! This crate's energy is `E(s) = −Σ h_i s_i − Σ_ij J_ij s_i s_j`. Loading `J_ij = w_ij` gives
//! `E = −Σ w s s`, so `cut = (W + E)/2` — and **minimising that energy minimises the cut**, which
//! is the opposite of the intended problem and produces a number that looks entirely plausible.
//!
//! Load `J_ij = −w_ij` instead. Then `E = +Σ w s s` and:
//!
//! ```text
//! cut = (W − E) / 2
//! ```
//!
//! so minimising energy maximises the cut, and a **lower** bound `L ≤ min E` becomes an **upper**
//! bound on the cut:
//!
//! ```text
//! max cut ≤ (W − L) / 2
//! ```
//!
//! [`Instance::cut`] and [`Instance::cut_upper_bound`] are the only places that arithmetic lives,
//! so a sign error is one edit rather than one per caller.
//!
//! # The file format
//!
//! One header line `n m`, then `m` lines of `i j w` with **1-based** vertex numbers. Weights are
//! `±1` in the G-set proper; this parser takes any integer so the same loader reads the weighted
//! variants.
use crate::graph::{Graph, GraphBuilder};
/// A parsed max-cut instance, ready to minimise.
pub struct Instance {
/// The Ising graph, couplings already **negated** so that minimising energy maximises the cut.
pub graph: Graph,
/// Σ of the edge weights, needed to convert energy to a cut value.
pub total_weight: f64,
pub nodes: usize,
pub edges: usize,
}
/// Why a G-set file could not be read.
#[derive(Clone, Debug, PartialEq)]
pub enum GsetError {
/// The header is missing or is not `n m`.
Header(String),
/// A body line is not `i j w`.
Line { line: usize, text: String },
/// A vertex number outside `1..=n`.
///
/// Named rather than clamped: the format is 1-based and an off-by-one silently shifts every
/// edge onto the wrong vertices, which still parses and still samples.
Vertex { line: usize, got: i64, n: usize },
/// The header promised `m` edges and the body had a different number.
///
/// A truncated download is the common cause, and it produces a *valid* smaller instance whose
/// cut values are quietly incomparable with everyone else's.
Count { declared: usize, found: usize },
}
impl core::fmt::Display for GsetError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
GsetError::Header(t) => write!(f, "expected a header line `n m`, got {t:?}"),
GsetError::Line { line, text } => write!(f, "line {line}: expected `i j w`, got {text:?}"),
GsetError::Vertex { line, got, n } => {
write!(f, "line {line}: vertex {got} is outside 1..={n}; this format is 1-based")
}
GsetError::Count { declared, found } => write!(
f,
"the header declares {declared} edges and the body has {found}. A truncated file \
parses into a valid SMALLER instance whose cut values are not comparable with \
anyone else's, so this is refused rather than solved"
),
}
}
}
/// Summary only, deliberately.
///
/// Deriving this would print the whole CSR, and a G-set instance has tens of thousands of edges --
/// so a failing `unwrap` would bury its own message under the graph it was complaining about.
impl core::fmt::Debug for Instance {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "Instance {{ nodes: {}, edges: {}, W: {} }}", self.nodes, self.edges, self.total_weight)
}
}
impl Instance {
/// Parse the G-set edge-list format.
pub fn parse(text: &str) -> Result<Instance, GsetError> {
let mut lines = text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty());
let (_, head) = lines.next().ok_or_else(|| GsetError::Header(String::new()))?;
let mut h = head.split_whitespace();
let n: usize = h
.next()
.and_then(|v| v.parse().ok())
.ok_or_else(|| GsetError::Header(head.to_string()))?;
let m: usize = h
.next()
.and_then(|v| v.parse().ok())
.ok_or_else(|| GsetError::Header(head.to_string()))?;
let mut gb = GraphBuilder::new(n);
let mut total = 0.0;
let mut found = 0usize;
for (no, l) in lines {
let mut it = l.split_whitespace();
let (Some(a), Some(b), Some(w)) = (it.next(), it.next(), it.next()) else {
return Err(GsetError::Line { line: no + 1, text: l.to_string() });
};
let (Ok(a), Ok(b), Ok(w)) = (a.parse::<i64>(), b.parse::<i64>(), w.parse::<f64>()) else {
return Err(GsetError::Line { line: no + 1, text: l.to_string() });
};
for v in [a, b] {
if v < 1 || v as usize > n {
return Err(GsetError::Vertex { line: no + 1, got: v, n });
}
}
// THE NEGATION. Everything in this module exists to get this sign right once.
gb.couple(a as usize - 1, b as usize - 1, -w);
total += w;
found += 1;
}
if found != m {
return Err(GsetError::Count { declared: m, found });
}
Ok(Instance { graph: gb.build(), total_weight: total, nodes: n, edges: m })
}
/// The cut a state achieves.
pub fn cut(&self, s: &[i8]) -> f64 {
(self.total_weight - self.graph.energy(s)) / 2.0
}
/// An upper bound on the max cut, from a lower bound on the energy.
///
/// `max cut = (W − min E)/2`, and `L ≤ min E`, so `(W − L)/2 ≥ max cut`. Every published G-set
/// result is a *lower* bound on the max cut (somebody found that cut); this is the other side,
/// which is what turns "best known" into a gap.
pub fn cut_upper_bound(&self, energy_lower_bound: f64) -> f64 {
(self.total_weight - energy_lower_bound) / 2.0
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A 5-cycle with unit weights. An odd cycle cannot be 2-coloured, so one edge always stays
/// inside a side and the max cut is 4 of 5 — small enough to check by hand and by enumeration.
const C5: &str = "5 5\n1 2 1\n2 3 1\n3 4 1\n4 5 1\n5 1 1\n";
fn brute_max_cut(inst: &Instance) -> f64 {
let n = inst.nodes;
let mut best = f64::NEG_INFINITY;
for mask in 0u32..(1u32 << n) {
let s: Vec<i8> = (0..n).map(|i| if mask >> i & 1 == 1 { 1 } else { -1 }).collect();
best = best.max(inst.cut(&s));
}
best
}
#[test]
fn the_sign_convention_maximises_the_cut_rather_than_minimising_it() {
// THE ERROR THIS MODULE EXISTS TO PREVENT. Load `J = +w` and minimising energy minimises
// the cut: a plausible number, a valid state, and the wrong problem. With `J = -w` the
// energy minimum must coincide with the cut maximum, checked by enumeration.
let inst = Instance::parse(C5).unwrap();
let n = inst.nodes;
let (mut lo_e, mut best_cut_at_lo_e) = (f64::INFINITY, 0.0);
for mask in 0u32..(1u32 << n) {
let s: Vec<i8> = (0..n).map(|i| if mask >> i & 1 == 1 { 1 } else { -1 }).collect();
let e = inst.graph.energy(&s);
if e < lo_e {
lo_e = e;
best_cut_at_lo_e = inst.cut(&s);
}
}
assert!((best_cut_at_lo_e - brute_max_cut(&inst)).abs() < 1e-9,
"the energy minimum must BE the cut maximum: {best_cut_at_lo_e} vs {}",
brute_max_cut(&inst));
assert!((brute_max_cut(&inst) - 4.0).abs() < 1e-9, "C5's max cut is 4 of 5 edges");
}
#[test]
fn the_bound_is_an_upper_bound_on_the_cut() {
// Every published G-set number is a LOWER bound on the max cut -- somebody found that cut.
// This is the other side, and it has to bracket the truth rather than merely be near it.
let inst = Instance::parse(C5).unwrap();
let b = crate::bound::forest(&inst.graph, 60);
let ub = inst.cut_upper_bound(b.value);
let truth = brute_max_cut(&inst);
assert!(ub >= truth - 1e-9, "upper bound {ub} sits BELOW the true max cut {truth}");
}
#[test]
fn the_cut_formula_survives_negative_weights() {
// COVERAGE GAP, found by asking. G-set carries +-1 weights, and every other test in this
// module uses an all-POSITIVE instance -- which cannot see a sign error that only appears
// when `w < 0`. `cut = (W - E)/2` was derived assuming nothing about the sign, so this
// checks it against the textbook definition directly: the sum of the weights of the edges
// crossing the cut, computed without going through the energy at all.
// Named, because the tuple-of-slice-of-tuples is over clippy's complexity limit inline --
// and the alias says what the second element IS, which the raw type did not.
type Edges = &'static [(usize, usize, f64)];
let cases: [(&str, Edges); 4] = [
("3 3\n1 2 1\n2 3 1\n1 3 1\n", &[(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0)]),
("3 3\n1 2 -1\n2 3 -1\n1 3 -1\n", &[(0, 1, -1.0), (1, 2, -1.0), (0, 2, -1.0)]),
(
"4 4\n1 2 1\n2 3 -1\n3 4 1\n4 1 -1\n",
&[(0, 1, 1.0), (1, 2, -1.0), (2, 3, 1.0), (3, 0, -1.0)],
),
(
"5 5\n1 2 -1\n2 3 1\n3 4 -1\n4 5 1\n5 1 -1\n",
&[(0, 1, -1.0), (1, 2, 1.0), (2, 3, -1.0), (3, 4, 1.0), (4, 0, -1.0)],
),
];
for (text, edges) in cases {
let inst = Instance::parse(text).unwrap();
for mask in 0u32..(1u32 << inst.nodes) {
let s: Vec<i8> =
(0..inst.nodes).map(|i| if mask >> i & 1 == 1 { 1 } else { -1 }).collect();
let textbook: f64 =
edges.iter().filter(|(a, b, _)| s[*a] != s[*b]).map(|(_, _, w)| *w).sum();
assert!(
(inst.cut(&s) - textbook).abs() < 1e-12,
"{text:?}: cut() gave {} and the crossing-edge sum is {textbook}",
inst.cut(&s)
);
}
}
}
#[test]
fn the_upper_bound_direction_holds_for_negative_weights_too() {
// `max cut <= (W - L)/2` is pure algebra from `cut = (W - E)/2` and `L <= min E`, so it
// cannot depend on the sign of any weight -- but "cannot depend" is exactly the kind of
// claim worth checking against enumeration rather than asserting.
let inst = Instance::parse("5 5\n1 2 -1\n2 3 1\n3 4 -1\n4 5 1\n5 1 -1\n").unwrap();
let truth = (0u32..(1u32 << inst.nodes))
.map(|m| {
let s: Vec<i8> =
(0..inst.nodes).map(|i| if m >> i & 1 == 1 { 1 } else { -1 }).collect();
inst.cut(&s)
})
.fold(f64::NEG_INFINITY, f64::max);
for b in [crate::bound::decoupled(&inst.graph), crate::bound::odd_cycle(&inst.graph, 6)] {
let ub = inst.cut_upper_bound(b.value);
assert!(ub >= truth - 1e-9, "{} gave upper bound {ub} below the true max cut {truth}", b.method);
}
}
#[test]
fn a_truncated_file_is_refused_rather_than_solved() {
// The common failure of a benchmark harness: a short download parses into a valid smaller
// instance, samples happily, and reports a cut nobody else's number can be compared to.
let short = "5 5\n1 2 1\n2 3 1\n";
assert_eq!(
Instance::parse(short).unwrap_err(),
GsetError::Count { declared: 5, found: 2 }
);
}
#[test]
fn one_based_vertices_are_enforced_not_assumed() {
// A 0 in the file means the producer was 0-based. Silently accepting it shifts every edge
// by one vertex, which still parses and still samples.
let zero = "3 1\n0 1 1\n";
assert!(matches!(Instance::parse(zero), Err(GsetError::Vertex { got: 0, .. })));
let over = "3 1\n1 4 1\n";
assert!(matches!(Instance::parse(over), Err(GsetError::Vertex { got: 4, n: 3, .. })));
}
#[test]
fn a_malformed_line_names_its_line_number() {
let bad = "3 2\n1 2 1\nnot an edge\n";
match Instance::parse(bad) {
Err(GsetError::Line { line, .. }) => assert_eq!(line, 3),
other => panic!("{other:?}"),
}
}
#[test]
fn weights_are_summed_for_the_cut_conversion() {
let w = Instance::parse("3 3\n1 2 2\n2 3 3\n1 3 5\n").unwrap();
assert!((w.total_weight - 10.0).abs() < 1e-12);
// All three on one side cuts nothing.
assert!((w.cut(&[1, 1, 1]) - 0.0).abs() < 1e-12);
// Splitting off vertex 1 cuts the two edges touching it: 2 + 5.
assert!((w.cut(&[1, -1, -1]) - 7.0).abs() < 1e-12);
}
}