diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3da455d0..9f6fdbf2 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -66,6 +66,7 @@ "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], "GraphPartitioning": [Graph Partitioning], + "GeneralizedHex": [Generalized Hex], "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], @@ -777,6 +778,64 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("GeneralizedHex") + let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) + let source = x.instance.source + let target = x.instance.target + let winning-path = ((0, 1), (1, 4), (4, 5)) + [ + #problem-def("GeneralizedHex")[ + Given an undirected graph $G = (V, E)$ and distinct terminals $s, t in V$, determine whether Player 1 has a forced win in the vertex-claiming Shannon switching game where the players alternately claim vertices of $V backslash {s, t}$, coloring them blue and red respectively, and Player 1 wins iff the final coloring contains an $s$-$t$ path whose internal vertices are all blue. + ][ + Generalized Hex is the vertex version of the Shannon switching game listed by Garey & Johnson (A8 GP1). Even and Tarjan proved that deciding whether the first player has a winning strategy is PSPACE-complete @evenTarjan1976. The edge-claiming Shannon switching game is a classical contrast point: Bruno and Weinberg showed that the edge version is polynomial-time solvable via matroid methods @brunoWeinberg1970. + + The implementation evaluates the decision problem directly rather than searching over candidate assignments. The instance has `dims() = []`, and `evaluate([])` runs a memoized minimax search over the ternary states (unclaimed, blue, red) of the nonterminal vertices. This preserves the alternating-game semantics of the original problem instead of collapsing the game into a static coloring predicate. + + *Example.* The canonical fixture uses the six-vertex graph with terminals $s = v_#source$ and $t = v_#target$, and edges #edges.map(((u, v)) => $(v_#u, v_#v)$).join(", "). Vertex $v_4$ is the unique neighbor of $t$, so Player 1 opens by claiming $v_4$. Player 2 can then block at most one of $v_1$, $v_2$, and $v_3$; Player 1 responds by claiming one of the remaining branch vertices, completing a blue path $v_0 arrow v_i arrow v_4 arrow v_5$. The fixture database therefore has exactly one satisfying configuration: the empty configuration, which triggers the internal game-tree evaluator on the initial board. + + #figure( + canvas(length: 1cm, { + import draw: * + let blue = graph-colors.at(0) + let gray = luma(185) + let verts = ( + (0, 1.0), + (1.6, 2.2), + (1.6, 1.0), + (1.6, -0.2), + (3.3, 1.0), + (5.0, 1.0), + ) + for (u, v) in edges { + let on-path = winning-path.any(e => + (e.at(0) == u and e.at(1) == v) or + (e.at(0) == v and e.at(1) == u) + ) + g-edge( + verts.at(u), + verts.at(v), + stroke: if on-path { 2pt + blue } else { 1pt + gray }, + ) + } + for (k, pos) in verts.enumerate() { + let highlighted = k == source or k == 1 or k == 4 or k == target + g-node( + pos, + name: "v" + str(k), + fill: if highlighted { blue } else { white }, + stroke: 1pt + if highlighted { blue } else { gray }, + label: text(fill: if highlighted { white } else { black })[$v_#k$], + ) + } + content((0, 1.55), text(8pt)[$s$]) + content((5.0, 1.55), text(8pt)[$t$]) + }), + caption: [A winning Generalized Hex instance. Player 1 first claims $v_4$, then answers any red move on $\{v_1, v_2, v_3\}$ by taking a different branch vertex and completing a blue path from $s = v_0$ to $t = v_5$.], + ) + ] + ] +} #{ let x = load-model-example("HamiltonianPath") let nv = graph-num-vertices(x.instance) @@ -3351,8 +3410,7 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], let conj = x.instance.conjuncts let nr = rels.len() let nc = conj.len() - let sol = x.optimal.at(0) - let assignment = sol.config + let assignment = x.optimal_config [ #problem-def("ConjunctiveBooleanQuery")[ Given a finite domain $D = {0, dots, d - 1}$, a collection of relations $R_0, R_1, dots, R_(m-1)$ where each $R_i$ is a set of $a_i$-tuples with entries from $D$, and a conjunctive Boolean query diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 995f7573..5d589fe3 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -137,6 +137,26 @@ @article{evenItaiShamir1976 doi = {10.1137/0205048} } +@article{evenTarjan1976, + author = {Shimon Even and Robert Endre Tarjan}, + title = {A Combinatorial Problem Which Is Complete in Polynomial Space}, + journal = {Journal of the ACM}, + volume = {23}, + number = {4}, + pages = {710--719}, + year = {1976} +} + +@article{brunoWeinberg1970, + author = {John Bruno and Louis Weinberg}, + title = {A Constructive Graph-Theoretic Solution of the Shannon Switching Game}, + journal = {IEEE Transactions on Circuit Theory}, + volume = {17}, + number = {1}, + pages = {74--81}, + year = {1970} +} + @article{glover2019, author = {Fred Glover and Gary Kochenberger and Yu Du}, title = {Quantum Bridge Analytics {I}: a tutorial on formulating and using {QUBO} models}, diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 730301a9..1ff70ff7 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -225,6 +225,7 @@ Flags by problem type: MinimumMultiwayCut --graph, --terminals, --edge-weights PartitionIntoTriangles --graph GraphPartitioning --graph + GeneralizedHex --graph, --source, --sink HamiltonianCircuit, HC --graph BoundedComponentSpanningForest --graph, --weights, --k, --bound UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 @@ -288,6 +289,7 @@ Examples: pred create MIS --graph 0-1,1-2,2-3 --weights 1,1,1 pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" pred create QUBO --matrix \"1,0.5;0.5,2\" + pred create GeneralizedHex --graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5 pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10 pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 41c37b3a..745e5a63 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,8 +9,9 @@ use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ClosestVectorProblem, ConsecutiveOnesSubmatrix, BMF}; use problemreductions::models::graph::{ - GraphPartitioning, HamiltonianCircuit, HamiltonianPath, LengthBoundedDisjointPaths, - MinimumMultiwayCut, MultipleChoiceBranching, SteinerTree, StrongConnectivityAugmentation, + GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, + LengthBoundedDisjointPaths, MinimumMultiwayCut, MultipleChoiceBranching, SteinerTree, + StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ BinPacking, CbqRelation, ConjunctiveBooleanQuery, FlowShopScheduling, LongestCommonSubsequence, @@ -288,6 +289,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", }, "GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3", + "GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5", "BoundedComponentSpanningForest" => { "--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6" } @@ -538,6 +540,9 @@ fn problem_help_flag_name( if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { return "bound".to_string(); } + if canonical == "GeneralizedHex" && field_name == "target" { + return "sink".to_string(); + } if canonical == "StringToStringCorrection" { return match field_name { "source" => "source-string".to_string(), @@ -698,6 +703,29 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // Generalized Hex (graph + source + sink) + "GeneralizedHex" => { + let usage = + "Usage: pred create GeneralizedHex --graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let num_vertices = graph.num_vertices(); + let source = args + .source + .ok_or_else(|| anyhow::anyhow!("GeneralizedHex requires --source\n\n{usage}"))?; + let sink = args + .sink + .ok_or_else(|| anyhow::anyhow!("GeneralizedHex requires --sink\n\n{usage}"))?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + if source == sink { + bail!("GeneralizedHex requires distinct --source and --sink\n\n{usage}"); + } + ( + ser(GeneralizedHex::new(graph, source, sink))?, + resolved_variant.clone(), + ) + } + // Hamiltonian Circuit (graph only, no weights) "HamiltonianCircuit" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -3631,6 +3659,26 @@ fn create_random( (ser(HamiltonianPath::new(graph))?, variant) } + // GeneralizedHex (graph only, with source/sink defaults) + "GeneralizedHex" => { + let num_vertices = num_vertices.max(2); + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let source = args.source.unwrap_or(0); + let sink = args.sink.unwrap_or(num_vertices - 1); + let usage = "Usage: pred create GeneralizedHex --random --num-vertices 6 [--edge-prob 0.5] [--seed 42] [--source 0] [--sink 5]"; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + if source == sink { + bail!("GeneralizedHex requires distinct --source and --sink\n\n{usage}"); + } + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(GeneralizedHex::new(graph, source, sink))?, variant) + } + // LengthBoundedDisjointPaths (graph only, with path defaults) "LengthBoundedDisjointPaths" => { let num_vertices = if num_vertices < 2 { @@ -3771,7 +3819,7 @@ fn create_random( "Random generation is not supported for {canonical}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman, \ - HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath)" + HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)" ), }; @@ -3786,15 +3834,26 @@ fn create_random( #[cfg(test)] mod tests { - use super::create; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + use clap::Parser; + use super::help_flag_hint; use super::help_flag_name; use super::parse_bool_rows; - use super::problem_help_flag_name; use super::*; use crate::cli::{Cli, Commands}; - use clap::Parser; - use std::time::{SystemTime, UNIX_EPOCH}; + use crate::output::OutputConfig; + + fn temp_output_path(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}.json", name, suffix)) + } #[test] fn test_problem_help_uses_bound_for_length_bounded_disjoint_paths() { @@ -3983,6 +4042,72 @@ mod tests { ); } + #[test] + fn test_create_generalized_hex_serializes_problem_json() { + let output = temp_output_path("generalized_hex_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "GeneralizedHex", + "--graph", + "0-1,0-2,0-3,1-4,2-4,3-4,4-5", + "--source", + "0", + "--sink", + "5", + ]) + .unwrap(); + let out = OutputConfig { + output: cli.output.clone(), + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + fs::remove_file(&output).unwrap(); + assert_eq!(json["type"], "GeneralizedHex"); + assert_eq!(json["variant"]["graph"], "SimpleGraph"); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["target"], 5); + } + + #[test] + fn test_create_generalized_hex_requires_sink() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "GeneralizedHex", + "--graph", + "0-1,1-2,2-3", + "--source", + "0", + ]) + .unwrap(); + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let err = create(&args, &out).unwrap_err(); + assert!(err.to_string().contains("GeneralizedHex requires --sink")); + } + fn empty_args() -> CreateArgs { CreateArgs { problem: Some("BiconnectivityAugmentation".to_string()), diff --git a/src/lib.rs b/src/lib.rs index 74fbc4a1..d37c4862 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,10 +46,10 @@ pub mod prelude { pub use crate::models::formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use crate::models::graph::{ BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, - BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GraphPartitioning, - HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KthBestSpanningTree, - LengthBoundedDisjointPaths, SpinGlass, SteinerTree, StrongConnectivityAugmentation, - SubgraphIsomorphism, + BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, + GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, + KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, + StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs new file mode 100644 index 00000000..29b6c870 --- /dev/null +++ b/src/models/graph/generalized_hex.rs @@ -0,0 +1,289 @@ +//! Generalized Hex problem implementation. +//! +//! Generalized Hex asks whether the first player has a forced win in the +//! vertex-claiming Shannon switching game on an undirected graph. + +use std::collections::{HashMap, VecDeque}; + +use serde::{Deserialize, Serialize}; + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::variant::VariantParam; + +inventory::submit! { + ProblemSchemaEntry { + name: "GeneralizedHex", + display_name: "Generalized Hex", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + module_path: module_path!(), + description: "Determine whether Player 1 has a forced blue path between two terminals", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "source", type_name: "usize", description: "The source terminal s" }, + FieldInfo { name: "target", type_name: "usize", description: "The target terminal t" }, + ], + } +} + +/// Generalized Hex on an undirected graph. +/// +/// The problem is represented as a zero-variable decision problem: the graph +/// instance fully determines the question, so `evaluate([])` runs a memoized +/// game-tree search from the initial empty board. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct GeneralizedHex { + graph: G, + source: usize, + target: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum ClaimState { + Unclaimed, + Blue, + Red, +} + +impl GeneralizedHex { + /// Create a new Generalized Hex instance. + pub fn new(graph: G, source: usize, target: usize) -> Self { + let num_vertices = graph.num_vertices(); + assert!(source < num_vertices, "source must be a valid graph vertex"); + assert!(target < num_vertices, "target must be a valid graph vertex"); + assert_ne!(source, target, "source and target must be distinct"); + Self { + graph, + source, + target, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the source terminal. + pub fn source(&self) -> usize { + self.source + } + + /// Get the target terminal. + pub fn target(&self) -> usize { + self.target + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Get the number of playable non-terminal vertices. + pub fn num_playable_vertices(&self) -> usize { + self.graph.num_vertices().saturating_sub(2) + } + + /// Check whether the first player has a forced win from the initial state. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if !config.is_empty() { + return false; + } + let playable_vertices = self.playable_vertices(); + let vertex_to_state_index = self.vertex_to_state_index(&playable_vertices); + let mut state = vec![ClaimState::Unclaimed; playable_vertices.len()]; + let mut memo = HashMap::new(); + self.first_player_wins(&mut state, &vertex_to_state_index, &mut memo) + } + + fn playable_vertices(&self) -> Vec { + (0..self.graph.num_vertices()) + .filter(|&vertex| vertex != self.source && vertex != self.target) + .collect() + } + + fn vertex_to_state_index(&self, playable_vertices: &[usize]) -> Vec> { + let mut index = vec![None; self.graph.num_vertices()]; + for (state_idx, &vertex) in playable_vertices.iter().enumerate() { + index[vertex] = Some(state_idx); + } + index + } + + fn first_player_wins( + &self, + state: &mut [ClaimState], + vertex_to_state_index: &[Option], + memo: &mut HashMap, bool>, + ) -> bool { + if self.has_path(state, vertex_to_state_index, |claim| { + matches!(claim, ClaimState::Blue) + }) { + return true; + } + if !self.has_path(state, vertex_to_state_index, |claim| { + claim != ClaimState::Red + }) { + return false; + } + if let Some(&cached) = memo.get(state) { + return cached; + } + + let blue_turn = state + .iter() + .filter(|&&claim| !matches!(claim, ClaimState::Unclaimed)) + .count() + % 2 + == 0; + + let result = if blue_turn { + let mut winning_move_found = false; + for idx in 0..state.len() { + if !matches!(state[idx], ClaimState::Unclaimed) { + continue; + } + state[idx] = ClaimState::Blue; + if self.first_player_wins(state, vertex_to_state_index, memo) { + winning_move_found = true; + state[idx] = ClaimState::Unclaimed; + break; + } + state[idx] = ClaimState::Unclaimed; + } + winning_move_found + } else { + let mut all_red_moves_still_win = true; + for idx in 0..state.len() { + if !matches!(state[idx], ClaimState::Unclaimed) { + continue; + } + state[idx] = ClaimState::Red; + if !self.first_player_wins(state, vertex_to_state_index, memo) { + all_red_moves_still_win = false; + state[idx] = ClaimState::Unclaimed; + break; + } + state[idx] = ClaimState::Unclaimed; + } + all_red_moves_still_win + }; + + memo.insert(state.to_vec(), result); + result + } + + fn has_path( + &self, + state: &[ClaimState], + vertex_to_state_index: &[Option], + allow_claim: F, + ) -> bool + where + F: Fn(ClaimState) -> bool, + { + let mut visited = vec![false; self.graph.num_vertices()]; + let mut queue = VecDeque::from([self.source]); + visited[self.source] = true; + + while let Some(vertex) = queue.pop_front() { + if vertex == self.target { + return true; + } + + for neighbor in self.graph.neighbors(vertex) { + if visited[neighbor] + || !self.vertex_is_allowed(neighbor, state, vertex_to_state_index, &allow_claim) + { + continue; + } + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + + false + } + + fn vertex_is_allowed( + &self, + vertex: usize, + state: &[ClaimState], + vertex_to_state_index: &[Option], + allow_claim: &F, + ) -> bool + where + F: Fn(ClaimState) -> bool, + { + if vertex == self.source || vertex == self.target { + return true; + } + vertex_to_state_index[vertex] + .and_then(|state_idx| state.get(state_idx).copied()) + .is_some_and(allow_claim) + } +} + +impl Problem for GeneralizedHex +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "GeneralizedHex"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![] + } + + fn evaluate(&self, config: &[usize]) -> bool { + if !config.is_empty() { + return false; + } + let playable_vertices = self.playable_vertices(); + let vertex_to_state_index = self.vertex_to_state_index(&playable_vertices); + let mut state = vec![ClaimState::Unclaimed; playable_vertices.len()]; + let mut memo = HashMap::new(); + self.first_player_wins(&mut state, &vertex_to_state_index, &mut memo) + } +} + +impl SatisfactionProblem for GeneralizedHex where G: Graph + VariantParam {} + +crate::declare_variants! { + default sat GeneralizedHex => "3^num_playable_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "generalized_hex_simplegraph", + instance: Box::new(GeneralizedHex::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (0, 3), (1, 4), (2, 4), (3, 4), (4, 5)], + ), + 0, + 5, + )), + optimal_config: vec![], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/generalized_hex.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 9a8740ab..9ba817e0 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -41,6 +41,7 @@ pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; pub(crate) mod directed_two_commodity_integral_flow; +pub(crate) mod generalized_hex; pub(crate) mod graph_partitioning; pub(crate) mod hamiltonian_circuit; pub(crate) mod hamiltonian_path; @@ -76,6 +77,7 @@ pub use biclique_cover::BicliqueCover; pub use biconnectivity_augmentation::BiconnectivityAugmentation; pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; pub use directed_two_commodity_integral_flow::DirectedTwoCommodityIntegralFlow; +pub use generalized_hex::GeneralizedHex; pub use graph_partitioning::GraphPartitioning; pub use hamiltonian_circuit::HamiltonianCircuit; pub use hamiltonian_path::HamiltonianPath; @@ -112,6 +114,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec GeneralizedHex { + GeneralizedHex::new( + SimpleGraph::new( + 8, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 5), + (3, 6), + (4, 6), + (5, 6), + (6, 7), + ], + ), + 0, + 7, + ) +} + +fn winning_example() -> GeneralizedHex { + GeneralizedHex::new( + SimpleGraph::new( + 6, + vec![(0, 1), (0, 2), (0, 3), (1, 4), (2, 4), (3, 4), (4, 5)], + ), + 0, + 5, + ) +} + +#[test] +fn test_generalized_hex_creation_and_getters() { + let problem = winning_example(); + assert_eq!(problem.source(), 0); + assert_eq!(problem.target(), 5); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.num_playable_vertices(), 4); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.graph().num_vertices(), 6); +} + +#[test] +fn test_generalized_hex_forced_win_on_bottleneck_example() { + let problem = winning_example(); + assert!(problem.evaluate(&[])); +} + +#[test] +fn test_generalized_hex_detects_losing_position() { + let problem = GeneralizedHex::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 0, 3); + assert!(!problem.evaluate(&[])); +} + +#[test] +fn test_generalized_hex_solver_returns_empty_config_for_win() { + let problem = winning_example(); + let solver = BruteForce::new(); + assert_eq!(solver.find_satisfying(&problem), Some(vec![])); + assert_eq!( + solver.find_all_satisfying(&problem), + Vec::>::from([vec![]]) + ); +} + +#[test] +fn test_generalized_hex_problem_name() { + assert_eq!( + as Problem>::NAME, + "GeneralizedHex" + ); +} + +#[test] +fn test_generalized_hex_serialization_round_trip() { + let problem = winning_example(); + let json = serde_json::to_string(&problem).unwrap(); + let decoded: GeneralizedHex = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.source(), 0); + assert_eq!(decoded.target(), 5); + assert!(decoded.evaluate(&[])); +} + +#[test] +fn test_generalized_hex_issue_example_is_losing_under_optimal_play() { + let problem = issue_example(); + assert!(!problem.evaluate(&[])); +} + +#[test] +fn test_generalized_hex_paper_example() { + let problem = winning_example(); + assert!(problem.evaluate(&[])); + assert_eq!(BruteForce::new().find_satisfying(&problem), Some(vec![])); +} + +#[test] +#[should_panic(expected = "source and target must be distinct")] +fn test_generalized_hex_rejects_identical_terminals() { + let _ = GeneralizedHex::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 1, 1); +}