use crate::graph::{Graph, GraphBuilder};
pub struct Instance {
pub graph: Graph,
pub total_weight: f64,
pub nodes: usize,
pub edges: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub enum GsetError {
Header(String),
Line { line: usize, text: String },
Vertex { line: usize, got: i64, n: usize },
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"
),
}
}
}
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 {
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 });
}
}
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 })
}
pub fn cut(&self, s: &[i8]) -> f64 {
(self.total_weight - self.graph.energy(s)) / 2.0
}
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::*;
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() {
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() {
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() {
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() {
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() {
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() {
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);
assert!((w.cut(&[1, 1, 1]) - 0.0).abs() < 1e-12);
assert!((w.cut(&[1, -1, -1]) - 7.0).abs() < 1e-12);
}
}