From 8fbd656874ba129df3fe90031b695090d5ff15bb Mon Sep 17 00:00:00 2001 From: Xiwei Pan <90967972+isPANN@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:19:09 -0800 Subject: [PATCH] Fix #1204: [Rule] MaximumCommonEdgeSubgraph to MaximumClique Co-Authored-By: Claude Sonnet 5.5 --- docs/paper/reductions.typ | 88 ++++ docs/paper/references.bib | 9 + src/rules/maximumcommonedgesubgraph_ilp.rs | 2 +- ...maximumcommonedgesubgraph_maximumclique.rs | 178 ++++++++ src/rules/mod.rs | 2 + ...maximumcommonedgesubgraph_maximumclique.rs | 390 ++++++++++++++++++ 6 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 src/rules/maximumcommonedgesubgraph_maximumclique.rs create mode 100644 src/unit_tests/rules/maximumcommonedgesubgraph_maximumclique.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 8e0462b7a..e4c0ee629 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -13984,6 +13984,94 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ For each source vertex $u in V_1$, output the unique $p in V_2$ with $x_(u,p) = 1$ if such a $p$ exists, otherwise the sentinel $|V_2|$ encoding "unmatched". ] +// Reduction: MaximumCommonEdgeSubgraph -> MaximumClique +#let mces_clq = load-example( + "MaximumCommonEdgeSubgraph", + "MaximumClique", + target-variant: (graph: "SimpleGraph", weight: "One"), +) +#let mces_clq_sol = mces_clq.solutions.at(0) +#let mces_clq_arcs1 = mces_clq.source.instance.graph_1.arcs +#let mces_clq_arcs2 = mces_clq.source.instance.graph_2.arcs +#let mces_clq_n2 = mces_clq.source.instance.graph_2.num_vertices +// Retained arc pairs (i, j, assignments), recomputed from the loaded instance. +#let mces_clq_cands = { + let out = () + for (i, a) in mces_clq_arcs1.enumerate() { + for (j, b) in mces_clq_arcs2.enumerate() { + if a.label == b.label and (a.src == a.dst) == (b.src == b.dst) { + out.push((i, j, ((a.src, b.src), (a.dst, b.dst)))) + } + } + } + out +} +#let mces_clq_adjacent(p, q) = p.at(2).all(x => q.at(2).all(y => (x.at(0) == y.at(0)) == (x.at(1) == y.at(1)))) +#let mces_clq_edges = { + let out = () + for p in range(mces_clq_cands.len()) { + for q in range(p + 1, mces_clq_cands.len()) { + if mces_clq_adjacent(mces_clq_cands.at(p), mces_clq_cands.at(q)) { out.push((p, q)) } + } + } + out +} +#let mces_clq_chosen = mces_clq_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, x)) => i) +#let mces_clq_map = { + let f = range(mces_clq.source.instance.graph_1.num_vertices).map(_ => mces_clq_n2) + for k in mces_clq_chosen { + for (s, t) in mces_clq_cands.at(k).at(2) { f.at(s) = t } + } + f +} +#let mces_clq_degree(v) = mces_clq_edges.filter(e => e.at(0) == v or e.at(1) == v).len() +#let mces_clq_isolated = range(mces_clq_cands.len()).filter(v => mces_clq_degree(v) == 0) +#let mces_clq_rest = range(mces_clq_cands.len()).filter(v => mces_clq_degree(v) > 0) +#assert(mces_clq_isolated.len() == 1) +#assert(mces_clq_edges.len() == mces_clq_rest.len() * (mces_clq_rest.len() - 1) / 2) +#assert(mces_clq_rest == mces_clq_chosen) +#assert(mces_clq_cands.len() == mces_clq.target.instance.graph.num_vertices) +#assert(mces_clq_edges == mces_clq.target.instance.graph.edges) +#assert(mces_clq_map == mces_clq_sol.source_config) +// NO example: a score of m_1 + 1 needs m_1 + 1 pairwise adjacent vertices. +#let mces_clq_threshold = mces_clq_arcs1.len() + 1 +#assert(mces_clq_edges.len() < mces_clq_threshold * (mces_clq_threshold - 1) / 2) +#assert(mces_clq_chosen.len() == mces_clq_arcs1.len()) +#reduction-rule("MaximumCommonEdgeSubgraph", "MaximumClique", + example: true, + example-target-variant: (graph: "SimpleGraph", weight: "One"), + example-caption: [Labelled 4-cycle against a 5-vertex digraph with a competing first arc], + extra: [ + #pred-commands( + "pred create --example " + rule-spec(mces_clq) + " -o source.json", + "pred reduce source.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate source.json --config " + cli-config(mces_clq_sol.source_config), + ) + *YES instance.* The source has $|V_1| = #mces_clq.source.instance.graph_1.num_vertices$ vertices and $|E_1| = #mces_clq_arcs1.len()$ arcs, the second graph $|V_2| = #mces_clq_n2$ vertices and $|E_2| = #mces_clq_arcs2.len()$ arcs. Exactly #mces_clq_cands.len() arc pairs have equal labels and equal loop status: + #table( + columns: 3, + [vertex], [pair $(e, e')$], [assignments], + ..mces_clq_cands.enumerate().map(((k, c)) => ( + [#k], + [$(e_#(c.at(0) + 1), e'_#(c.at(1) + 1))$], + [$#c.at(2).map(x => $#x.at(0) -> #x.at(1)$).join(", ")$], + )).flatten(), + ) + The target has #mces_clq.target.instance.graph.num_vertices vertices and #mces_clq.target.instance.graph.edges.len() edges $#mces_clq_edges.map(e => $(#e.at(0), #e.at(1))$).join(", ")$; vertex #mces_clq_isolated.map(str).join(", ") has degree 0 and the remaining #mces_clq_rest.len() vertices are pairwise adjacent, a $K_#mces_clq_rest.len()$. The witness clique $C = {#mces_clq_chosen.map(str).join(", ")}$ extracts to the map $(#fmt-values(mces_clq_map))$, which preserves all #mces_clq_arcs1.len() source arcs (sentinel #mces_clq_n2 means unmatched). + + *NO instance.* A map preserving #mces_clq_threshold arcs would need a clique of #mces_clq_threshold vertices, hence $#(mces_clq_threshold * (mces_clq_threshold - 1) / 2)$ edges, but the target has only #mces_clq_edges.len(); the source cannot exceed $|E_1| = #mces_clq_arcs1.len()$ arcs either. Both answers are NO at threshold #mces_clq_threshold, and the optimum #mces_clq_chosen.len() is the clique number. + ], +)[ + Build an association graph on the label-compatible pairs of a source arc and a target arc: a clique is a set of arc correspondences whose endpoint assignments form one partial injective vertex map, so the maximum clique size equals the maximum number of preserved arcs. For the restricted class of weakly connected, simple, oriented, node-labelled digraphs, a related clique formulation through extended line digraphs is given in @NeremEtAl2020DirectedMCES; the construction here is proved directly for general edge-labelled digraphs with loops, disconnected graphs and partial maps. +][ + _Construction._ Let $G_i = (V_i, E_i)$ with $E_i subset.eq V_i times Sigma times V_i$ and $E_1, E_2$ in their stored order. A pair $(e, e')$ of $e = (u, lambda, v) in E_1$ and $e' = (a, mu, b) in E_2$ is _retained_ iff $lambda = mu$ and $(u = v) <=> (a = b)$. The target graph $H$ has one unit-weight vertex per retained pair, ordered by $e$ and then $e'$, carrying the assignments $A_p = {(u, a), (v, b)}$ (a single assignment for loops). Distinct $p, q$ are adjacent iff $(s = s') <=> (t = t')$ for all $(s, t) in A_p$ and $(s', t') in A_q$, i.e. iff $A_p union A_q$ is a function and injective. $H$ has at most $|E_1| |E_2|$ vertices and at most $binom(|E_1| |E_2|, 2)$ edges. + + _Correctness._ ($arrow.r.double$) Let $f$ be a partial injection preserving the arcs $e_1, dots, e_k$ and let $e'_r = (f(u_r), lambda_r, f(v_r))$ be the target arc of $e_r$ (unique, as arc sets are sets). The pair $(e_r, e'_r)$ is retained: labels agree, and $u_r = v_r <=> f(u_r) = f(v_r)$ because $f$ is injective. All assignments lie in the graph of $f$, so their unions are functions and injective; the $k$ pairs are pairwise adjacent, giving a clique of size $k$. ($arrow.l.double$) Let $C$ be a clique of size $c$ and $g = union_(p in C) A_p$. Pairwise adjacency makes $g$ a partial injection, and each selected pair $(e, e')$ has $g(u) = a$, $g(v) = b$ with $e' = (a, lambda, b) in E_2$, so $e$ is preserved. Two selected pairs cannot share the source arc $e$: agreement on $u$ and $v$ would force the same endpoints and label of the target arc, hence the same $e'$. So $g$ preserves at least $c$ arcs. Both optima coincide, and $max |C| <= min(|E_1|, |E_2|)$. + + _Solution extraction._ Validate that the selection is a clique, set every source vertex to the sentinel $|V_2|$, then write the assignments of each selected vertex. A maximum clique extracts an optimal map; a non-maximum clique can extract a map that preserves more arcs than $|C|$ (three consecutive arcs of a labelled 4-cycle determine all four images), so only the completed optimum values map identically. +] + #let cmo_ilp = load-example( "MaximumContactMapOverlap", "ILP", diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 0dda81fd2..e92f800f2 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -2352,3 +2352,12 @@ @article{een2006 pages = {1--26}, doi = {10.3233/SAT190014}, } + +@misc{NeremEtAl2020DirectedMCES, + author = {R. R. Nerem and P. Crawford-Kahrl and B. Cummins and T. Gedeon}, + title = {A poset metric from the directed maximum common edge subgraph}, + year = {2020}, + eprint = {1910.14638}, + archivePrefix = {arXiv}, + url = {https://arxiv.org/abs/1910.14638} +} diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index ba8a89094..8994679a0 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -130,7 +130,7 @@ impl ReduceTo> for MaximumCommonEdgeSubgraph { let objective: Vec<(usize, i64)> = (0..num_y).map(|seq| (y_idx(seq), 1)).collect(); let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Maximize) - .map_err(Self::target_construction)?; + .map_err(>>::target_construction)?; Ok(ReductionMCESToILP { target, diff --git a/src/rules/maximumcommonedgesubgraph_maximumclique.rs b/src/rules/maximumcommonedgesubgraph_maximumclique.rs new file mode 100644 index 000000000..27a389221 --- /dev/null +++ b/src/rules/maximumcommonedgesubgraph_maximumclique.rs @@ -0,0 +1,178 @@ +//! Reduction from MaximumCommonEdgeSubgraph to MaximumClique via an association graph. +//! +//! Every pair of a source arc `(u, l, v)` and a target arc `(a, l, b)` with the +//! same label and the same loop status becomes a vertex that proposes the +//! assignments `u -> a` and `v -> b`. Two vertices are adjacent exactly when the +//! union of their assignments is a partial injection. A clique therefore +//! describes a partial injective vertex map that preserves at least as many +//! arcs as the clique has vertices, and the maximum clique size equals the +//! optimum of the source problem. + +use crate::models::graph::{MaximumClique, MaximumCommonEdgeSubgraph}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult, validate_target_witness}; +use crate::rules::{ExtractionResult, ReductionError}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::One; +use std::collections::HashSet; + +/// Endpoint assignments `(source vertex, target vertex)` proposed by one candidate. +type Assignments = [(usize, usize); 2]; + +/// Result of reducing MaximumCommonEdgeSubgraph to MaximumClique. +/// +/// Target vertex `k` is the `k`-th retained arc pair, ordered by source arc +/// index and then by target arc index. +#[derive(Debug, Clone)] +pub struct ReductionMCESToClique { + target: MaximumClique, + num_vertices_1: usize, + num_vertices_2: usize, + assignments: Vec, +} + +impl ReductionResult for ReductionMCESToClique { + type Source = MaximumCommonEdgeSubgraph; + type Target = MaximumClique; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Start with every source vertex unmatched (sentinel `|V2|`), then write + /// the endpoint assignments of each selected candidate. Pairwise adjacency + /// guarantees that the writes agree. + fn extract_solution( + &self, + target_solution: &::Solution, + ) -> ExtractionResult<::Solution> { + validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0.is_some(), + "selected vertices are not a clique", + )?; + + let mut mapping = vec![self.num_vertices_2; self.num_vertices_1]; + for (selected, assignment) in target_solution.iter().zip(&self.assignments) { + if *selected { + for &(source_vertex, target_vertex) in assignment { + mapping[source_vertex] = target_vertex; + } + } + } + Ok(mapping) + } +} + +#[crate::aggregate_reduction(identity)] +impl crate::rules::traits::AggregateReductionResult for ReductionMCESToClique {} + +/// Two candidates are compatible when equal source vertices carry equal target +/// vertices and different source vertices carry different target vertices. +fn compatible(left: &Assignments, right: &Assignments) -> bool { + left.iter() + .all(|&(s, t)| right.iter().all(|&(s2, t2)| (s == s2) == (t == t2))) +} + +// At most one vertex per (source arc, target arc) pair, and at most one edge per +// vertex pair; the label and loop filters only remove vertices and edges. +#[reduction(transform = upper_bound { + num_vertices = "num_arcs_1 * num_arcs_2", + num_edges = "num_arcs_1 * num_arcs_2 * (num_arcs_1 * num_arcs_2 - 1) / 2", +})] +impl ReduceTo> for MaximumCommonEdgeSubgraph { + type Result = ReductionMCESToClique; + + fn reduce_to(&self) -> Result { + type Target = MaximumClique; + let invalid = |message: String| ReductionError::invalid_target::(message); + let (n1, n2) = (self.num_vertices_1(), self.num_vertices_2()); + for (name, graph, bound) in [ + ("first", self.graph_1(), n1), + ("second", self.graph_2(), n2), + ] { + let mut seen = HashSet::new(); + for arc in graph.arcs() { + if arc.src >= bound || arc.dst >= bound { + return Err(invalid(format!( + "an arc of the {name} graph has an endpoint outside 0..{bound}" + ))); + } + if !seen.insert(*arc) { + return Err(invalid(format!("the {name} graph repeats an arc"))); + } + } + } + + let mut assignments: Vec = Vec::new(); + for source_arc in self.graph_1().arcs() { + for target_arc in self.graph_2().arcs() { + let same_loop_status = + (source_arc.src == source_arc.dst) == (target_arc.src == target_arc.dst); + if source_arc.label == target_arc.label && same_loop_status { + assignments.push([ + (source_arc.src, target_arc.src), + (source_arc.dst, target_arc.dst), + ]); + } + } + } + + let mut edges = Vec::new(); + for (p, left) in assignments.iter().enumerate() { + for (offset, right) in assignments[p + 1..].iter().enumerate() { + if compatible(left, right) { + edges.push((p, p + 1 + offset)); + } + } + } + + let graph = SimpleGraph::try_new(assignments.len(), edges) + .map_err(>::target_construction)?; + let weights = vec![One; assignments.len()]; + Ok(ReductionMCESToClique { + target: MaximumClique::new(graph, weights), + num_vertices_1: n1, + num_vertices_2: n2, + assignments, + }) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::graph::{LabelledArc, LabelledDigraph}; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "maximumcommonedgesubgraph_to_maximumclique", + build: || { + let arcs = |triples: &[(usize, usize, usize)]| { + triples + .iter() + .map(|&(u, label, v)| LabelledArc::new(u, label, v)) + .collect::>() + }; + let source = MaximumCommonEdgeSubgraph::new( + LabelledDigraph::new(4, arcs(&[(0, 0, 1), (1, 1, 2), (2, 2, 3), (3, 3, 0)])), + LabelledDigraph::new( + 5, + arcs(&[(0, 0, 1), (1, 1, 3), (3, 2, 4), (4, 3, 0), (2, 0, 3)]), + ), + ); + crate::example_db::specs::rule_example_with_witness::<_, MaximumClique>( + source, + SolutionPair { + source_config: serde_json::json!([0, 1, 3, 4]), + target_config: serde_json::json!([true, false, true, true, true]), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/maximumcommonedgesubgraph_maximumclique.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7606f4923..29acbbb1d 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -239,6 +239,7 @@ pub(crate) mod maximum2satisfiability_ilp; pub(crate) mod maximumclique_ilp; pub(crate) mod maximumcokplex_ilp; pub(crate) mod maximumcommonedgesubgraph_ilp; +pub(crate) mod maximumcommonedgesubgraph_maximumclique; pub(crate) mod maximumcontactmapoverlap_ilp; pub(crate) mod maximumdomaticnumber_ilp; pub(crate) mod maximumedgeweightedkclique_ilp; @@ -431,6 +432,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec = &'a [(usize, usize, usize)]; + +fn digraph(num_vertices: usize, arcs: Triples) -> LabelledDigraph { + LabelledDigraph::new( + num_vertices, + arcs.iter() + .map(|&(u, label, v)| LabelledArc::new(u, label, v)) + .collect(), + ) +} + +fn instance(n1: usize, a1: Triples, n2: usize, a2: Triples) -> MaximumCommonEdgeSubgraph { + MaximumCommonEdgeSubgraph::new(digraph(n1, a1), digraph(n2, a2)) +} + +fn reduce(source: &MaximumCommonEdgeSubgraph) -> ReductionMCESToClique { + ReduceTo::>::reduce_to(source).unwrap() +} + +/// The labelled four-cycle against a five-vertex graph with a competing first arc. +fn cycle_instance() -> MaximumCommonEdgeSubgraph { + instance( + 4, + &[(0, 0, 1), (1, 1, 2), (2, 2, 3), (3, 3, 0)], + 5, + &[(0, 0, 1), (1, 1, 3), (3, 2, 4), (4, 3, 0), (2, 0, 3)], + ) +} + +fn optimum_value

(problem: &P) -> P::Value +where + P: Problem + 'static, + P::Solution: 'static, + P::Value: crate::types::SolutionAggregate + 'static, +{ + BruteForce::new().solve_with_witnesses(problem).unwrap().0 +} + +fn num_candidates(source: &MaximumCommonEdgeSubgraph) -> usize { + ReductionResult::target_problem(&reduce(source)).num_vertices() +} + +fn source_value(source: &MaximumCommonEdgeSubgraph, config: &[usize]) -> i64 { + source.evaluate(&config.to_vec()).unwrap().0.unwrap() +} + +fn selection(size: usize, chosen: &[usize]) -> Vec { + (0..size).map(|index| chosen.contains(&index)).collect() +} + +/// Every clique of the target (including the empty one) as an increasing vertex list. +fn all_cliques(target: &MaximumClique) -> Vec> { + fn grow( + graph: &SimpleGraph, + current: &mut Vec, + candidates: Vec, + out: &mut Vec>, + ) { + out.push(current.clone()); + for (position, &vertex) in candidates.iter().enumerate() { + let rest = candidates[position + 1..] + .iter() + .copied() + .filter(|&other| graph.has_edge(vertex, other)) + .collect(); + current.push(vertex); + grow(graph, current, rest, out); + current.pop(); + } + } + let mut out = Vec::new(); + grow( + target.graph(), + &mut Vec::new(), + (0..target.num_vertices()).collect(), + &mut out, + ); + out +} + +/// Optimum of the source by trying every map. +fn source_optimum(source: &MaximumCommonEdgeSubgraph) -> i64 { + optimum_value(source) + .0 + .expect("the empty map is always feasible") +} + +/// Checks the validation plan's relations on one instance: optimum equality, extraction +/// feasibility and dominance for every clique, equality at maximum cliques. +fn check_every_clique(source: &MaximumCommonEdgeSubgraph) { + let reduction = reduce(source); + let target = ReductionResult::target_problem(&reduction); + let cliques = all_cliques(target); + let best = cliques.iter().map(Vec::len).max().unwrap() as i64; + let optimum = source_optimum(source); + assert_eq!(best, optimum); + for chosen in &cliques { + let config = reduction + .extract_solution(&selection(target.num_vertices(), chosen)) + .unwrap(); + assert!(source.is_valid_solution(&config)); + let value = source_value(source, &config); + assert!(value >= chosen.len() as i64); + if chosen.len() as i64 == best { + assert_eq!(value, optimum); + } + } +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_closed_loop() { + let source = cycle_instance(); + let reduction = reduce(&source); + let target = ReductionResult::target_problem(&reduction); + assert_eq!(target.num_vertices(), 5); + assert_eq!(target.num_edges(), 6); + assert_parameter_predictions(&source, &reduction); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "MaximumCommonEdgeSubgraph->MaximumClique closed loop", + ); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_issue_example() { + let source = cycle_instance(); + let reduction = reduce(&source); + let target = ReductionResult::target_problem(&reduction); + // Candidates in order: (0,0), (0,4), (1,1), (2,2), (3,3). + let mut edges = target.graph().edges(); + edges.sort_unstable(); + assert_eq!(edges, vec![(0, 2), (0, 3), (0, 4), (2, 3), (2, 4), (3, 4)]); + assert!( + edges.iter().all(|&(left, right)| left != 1 && right != 1), + "the competing candidate is isolated" + ); + let optimal = reduction + .extract_solution(&vec![true, false, true, true, true]) + .unwrap(); + assert_eq!(optimal, vec![0, 1, 3, 4]); + assert_eq!(source_value(&source, &optimal), 4); + // Suboptimal cliques, with sentinel 5 for unmatched vertices. + let two = reduction + .extract_solution(&vec![true, false, true, false, false]) + .unwrap(); + assert_eq!(two, vec![0, 1, 3, 5]); + assert_eq!(source_value(&source, &two), 2); + let one = reduction + .extract_solution(&vec![false, true, false, false, false]) + .unwrap(); + assert_eq!(one, vec![2, 3, 5, 5]); + assert_eq!(source_value(&source, &one), 1); + // Threshold five is infeasible on both sides: the optimum is four. + assert_eq!(source_optimum(&source), 4); + assert_eq!(optimum_value(target), Max(Some(4))); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_exhaustive_small_graph_pairs() { + // Every unlabelled looped digraph pair with at most two vertices. + let graphs = |n: usize| -> Vec> { + let slots: Vec<_> = (0..n) + .flat_map(|u| (0..n).map(move |v| (u, 0, v))) + .collect(); + (0u32..1 << slots.len()) + .map(|mask| { + slots + .iter() + .enumerate() + .filter(|(k, _)| mask >> k & 1 == 1) + .map(|(_, arc)| *arc) + .collect() + }) + .collect() + }; + let mut count = 0; + for n1 in 0..=2 { + for n2 in 0..=2 { + for first in graphs(n1) { + for second in graphs(n2) { + check_every_clique(&instance(n1, &first, n2, &second)); + count += 1; + } + } + } + } + assert_eq!(count, 19 * 19); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_labelled_samples() { + // Deterministic pseudo-random labelled instances: 3 labels, up to 6 arcs, up to 5 vertices. + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut next = |bound: usize| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state % bound as u64) as usize + }; + for round in 0..75 { + let (n1, n2) = (1 + round % 5, 1 + (round / 5) % 5); + let mut draw = |n: usize| { + let mut arcs: Vec<(usize, usize, usize)> = Vec::new(); + for _ in 0..next(7) { + let arc = (next(n), next(3), next(n)); + if !arcs.contains(&arc) { + arcs.push(arc); + } + } + arcs + }; + let (first, second) = (draw(n1), draw(n2)); + let source = instance(n1, &first, n2, &second); + check_every_clique(&source); + } +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_nonoptimal_clique_scores_higher() { + // Three consecutive arcs of a labelled four-cycle fix all four images and preserve the fourth. + let cycle: Triples = &[(0, 0, 1), (1, 0, 2), (2, 0, 3), (3, 0, 0)]; + let source = instance(4, cycle, 4, cycle); + let reduction = reduce(&source); + let target = ReductionResult::target_problem(&reduction); + // The identity candidates are the pairs (0,0), (1,1), (2,2): vertices 0, 5, 10. + let chosen = [0, 5, 10]; + let selected = selection(target.num_vertices(), &chosen); + assert_eq!(target.evaluate(&selected).unwrap(), Max(Some(3))); + let config = reduction.extract_solution(&selected).unwrap(); + assert_eq!(config, vec![0, 1, 2, 3]); + assert!(source_value(&source, &config) > 3); + assert_eq!(source_optimum(&source), 4); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_special_cases() { + // Empty arc sets: empty target, all-unmatched extraction. + let source = instance(3, &[], 2, &[(0, 0, 1)]); + let reduction = reduce(&source); + assert_eq!( + ReductionResult::target_problem(&reduction).num_vertices(), + 0 + ); + assert_eq!(reduction.extract_solution(&vec![]).unwrap(), vec![2, 2, 2]); + let source = instance(2, &[(0, 0, 1)], 3, &[]); + assert_eq!(num_candidates(&source), 0); + // Loop against non-loop is discarded, and so is the converse. + let source = instance(2, &[(0, 0, 0)], 2, &[(0, 0, 1), (1, 0, 0)]); + assert_eq!(num_candidates(&source), 0); + let source = instance(2, &[(0, 0, 1)], 1, &[(0, 0, 0)]); + assert_eq!(num_candidates(&source), 0); + // Loops map to loops; two source loops cannot share one target loop. + let source = instance(2, &[(0, 0, 0), (1, 0, 1)], 2, &[(0, 0, 0)]); + let reduction = reduce(&source); + assert_eq!( + ReductionResult::target_problem(&reduction).num_vertices(), + 2 + ); + assert_eq!(ReductionResult::target_problem(&reduction).num_edges(), 0); + check_every_clique(&source); + // More source than target vertices: the map is partial. + check_every_clique(&instance( + 4, + &[(0, 0, 1), (1, 0, 2), (2, 0, 3)], + 2, + &[(0, 0, 1), (1, 0, 0)], + )); + // Antiparallel arcs, and several labels on one ordered pair, stay distinct arcs. + check_every_clique(&instance( + 2, + &[(0, 0, 1), (1, 0, 0)], + 2, + &[(0, 0, 1), (1, 0, 0)], + )); + check_every_clique(&instance( + 2, + &[(0, 0, 1), (0, 1, 1)], + 2, + &[(0, 0, 1), (1, 1, 0)], + )); + // Reversed arcs are not identified without a vertex swap; disconnected sources are fine. + check_every_clique(&instance(2, &[(0, 0, 1)], 2, &[(1, 0, 0)])); + check_every_clique(&instance( + 4, + &[(0, 0, 1), (2, 0, 3)], + 3, + &[(0, 0, 1), (1, 0, 2)], + )); + // Arbitrary labels. + check_every_clique(&instance( + 3, + &[(0, 40, 1), (1, 7, 2), (2, 40, 0)], + 3, + &[(2, 40, 1), (1, 7, 0), (0, 40, 2)], + )); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_infeasible_threshold_instance() { + // Disjoint label sets: no arc can be preserved, so the optimum is zero. + let source = instance( + 3, + &[(0, 0, 1), (1, 0, 2), (2, 0, 0)], + 3, + &[(0, 1, 1), (1, 1, 2), (2, 1, 0)], + ); + let reduction = reduce(&source); + assert_eq!( + ReductionResult::target_problem(&reduction).num_vertices(), + 0 + ); + let value = optimum_value(ReductionResult::target_problem(&reduction)); + assert_eq!(value, Max(Some(0))); + assert_eq!( + AggregateReductionResult::extract_value(&reduction, value), + source.evaluate(&vec![3, 3, 3]).unwrap() + ); + assert_eq!(source_optimum(&source), 0); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_extract_rejects_invalid_witnesses() { + let source = cycle_instance(); + let reduction = reduce(&source); + // Vertices 0 and 1 are not adjacent. + assert!( + reduction + .extract_solution(&vec![true, true, false, false, false]) + .is_err() + ); + // Wrong lengths. + assert!(reduction.extract_solution(&vec![]).is_err()); + assert!( + reduction + .extract_solution(&vec![true, false, true, true, true, false]) + .is_err() + ); + assert!(reduction.extract_solution(&vec![true; 4]).is_err()); +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_rejects_malformed_raw_graphs() { + let good = digraph(2, &[(0, 0, 1)]); + let duplicated = LabelledDigraph { + num_vertices: 2, + arcs: vec![LabelledArc::new(0, 0, 1), LabelledArc::new(0, 0, 1)], + }; + let outside = LabelledDigraph { + num_vertices: 2, + arcs: vec![LabelledArc::new(0, 0, 2)], + }; + for (first, second) in [ + (duplicated.clone(), good.clone()), + (good.clone(), duplicated), + (outside.clone(), good.clone()), + (good, outside), + ] { + let source = MaximumCommonEdgeSubgraph::new(first, second); + let error = ReduceTo::>::reduce_to(&source) + .expect_err("malformed raw representation must be rejected"); + assert!(matches!(error, ReductionError::InvalidTarget { .. })); + } +} + +#[test] +fn test_maximumcommonedgesubgraph_to_maximumclique_aggregate_value_matches_brute_force() { + for source in [ + cycle_instance(), + instance(3, &[(0, 0, 1)], 3, &[(0, 0, 1), (1, 0, 2)]), + instance(2, &[], 2, &[]), + ] { + let reduction = reduce(&source); + let target_value = optimum_value(ReductionResult::target_problem(&reduction)); + assert_eq!( + AggregateReductionResult::extract_value(&reduction, target_value), + optimum_value(&source) + ); + } +}