Skip to content

Commit fed65e9

Browse files
GiggleLiuisPANNclaude
authored
Fix #438: [Model] PartialFeedbackEdgeSet (#755)
* Add plan for #438: [Model] PartialFeedbackEdgeSet * Implement #438: [Model] PartialFeedbackEdgeSet * chore: remove plan file for #438 * chore: fix rustfmt formatting in export_module_graph.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: deduplicate normalize_edge between model and test Widen the feature gate to `#[cfg(any(feature = "example-db", test))]` so the test module can reuse the parent's normalize_edge via super::. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ccf12f4 commit fed65e9

9 files changed

Lines changed: 602 additions & 33 deletions

File tree

docs/paper/reductions.typ

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"ExactCoverBy3Sets": [Exact Cover by 3-Sets],
133133
"SubsetSum": [Subset Sum],
134134
"Partition": [Partition],
135+
"PartialFeedbackEdgeSet": [Partial Feedback Edge Set],
135136
"MinimumFeedbackArcSet": [Minimum Feedback Arc Set],
136137
"MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set],
137138
"ConjunctiveBooleanQuery": [Conjunctive Boolean Query],
@@ -4804,6 +4805,65 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76],
48044805
]
48054806
}
48064807

4808+
#{
4809+
let x = load-model-example("PartialFeedbackEdgeSet")
4810+
let nv = graph-num-vertices(x.instance)
4811+
let edges = x.instance.graph.edges
4812+
let ne = edges.len()
4813+
let K = x.instance.budget
4814+
let L = x.instance.max_cycle_length
4815+
let config = x.optimal_config
4816+
let removed-indices = config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
4817+
let removed-edges = removed-indices.map(i => edges.at(i))
4818+
let blue = graph-colors.at(0)
4819+
let gray = luma(180)
4820+
[
4821+
#problem-def("PartialFeedbackEdgeSet")[
4822+
Given an undirected graph $G = (V, E)$, a budget $K in ZZ_(>= 0)$, and a cycle-length bound $L in ZZ_(>= 0)$, determine whether there exists a subset $E' subset.eq E$ with $|E'| <= K$ such that every simple cycle in $G$ of length at most $L$ contains at least one edge of $E'$.
4823+
][
4824+
Partial Feedback Edge Set is the bounded-cycle edge-deletion problem GT9 in Garey and Johnson @garey1979. Bounding the cycle length is what makes the problem hard: hitting only the short cycles is NP-complete, whereas the unrestricted undirected feedback-edge-set problem is polynomial-time solvable by reducing to a spanning forest. The implementation here uses one binary variable per edge, so brute-force search explores $O^*(2^|E|)$ candidate edge subsets.#footnote[No sharper general exact worst-case bound is claimed here.]
4825+
4826+
*Example.* Consider the graph $G$ with $n = #nv$ vertices, $|E| = #ne$ edges, budget $K = #K$, and length bound $L = #L$. Removing
4827+
$E' = {#removed-edges.map(e => [$\{v_#(e.at(0)), v_#(e.at(1))\}$]).join(", ")}$
4828+
hits the triangles $(v_0, v_1, v_2)$, $(v_0, v_2, v_3)$, $(v_2, v_3, v_4)$, and $(v_3, v_4, v_5)$, together with the 4-cycles $(v_0, v_1, v_2, v_3)$, $(v_0, v_2, v_4, v_3)$, and $(v_2, v_3, v_5, v_4)$. Hence every cycle of length at most 4 is hit. Brute-force search on this instance finds exactly five satisfying 3-edge deletions and none of size 2, so the displayed configuration certifies a YES-instance.
4829+
4830+
#pred-commands(
4831+
"pred create --example PartialFeedbackEdgeSet -o partial-feedback-edge-set.json",
4832+
"pred solve partial-feedback-edge-set.json",
4833+
"pred evaluate partial-feedback-edge-set.json --config " + x.optimal_config.map(str).join(","),
4834+
)
4835+
4836+
#figure(
4837+
canvas(length: 1cm, {
4838+
let verts = (
4839+
(0, 1.4),
4840+
(1.2, 2.4),
4841+
(1.9, 1.0),
4842+
(3.3, 1.4),
4843+
(4.5, 2.4),
4844+
(4.5, 0.4),
4845+
)
4846+
for edge in edges {
4847+
let (u, v) = edge
4848+
let selected = removed-edges.any(e =>
4849+
(e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)
4850+
)
4851+
g-edge(
4852+
verts.at(u),
4853+
verts.at(v),
4854+
stroke: if selected { 2pt + blue } else { 1pt + gray },
4855+
)
4856+
}
4857+
for (idx, pos) in verts.enumerate() {
4858+
g-node(pos, name: "v" + str(idx), label: [$v_#idx$])
4859+
}
4860+
}),
4861+
caption: [Partial Feedback Edge Set example with $K = 3$ and $L = 4$. Blue edges $\{v_0, v_2\}$, $\{v_2, v_3\}$, and $\{v_3, v_4\}$ form a satisfying edge set that hits every cycle of length at most 4.],
4862+
) <fig:partial-feedback-edge-set>
4863+
]
4864+
]
4865+
}
4866+
48074867
#{
48084868
let x = load-model-example("MultipleChoiceBranching")
48094869
let nv = graph-num-vertices(x.instance)

examples/export_module_graph.rs

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,7 @@ fn main() {
9292
for entry in inventory::iter::<ProblemSchemaEntry> {
9393
let display = module_display_path(entry.module_path);
9494
let category = module_category(entry.module_path).to_string();
95-
module_categories
96-
.entry(display.clone())
97-
.or_insert(category);
95+
module_categories.entry(display.clone()).or_insert(category);
9896
module_items.entry(display).or_default().push(ModuleItem {
9997
name: entry.display_name.to_string(),
10098
kind: "struct".to_string(),
@@ -103,13 +101,21 @@ fn main() {
103101
}
104102

105103
// Add well-known non-model modules with their key items
106-
type ModuleSpec = (&'static str, &'static str, &'static [(&'static str, &'static str, &'static str)]);
104+
type ModuleSpec = (
105+
&'static str,
106+
&'static str,
107+
&'static [(&'static str, &'static str, &'static str)],
108+
);
107109
let static_modules: &[ModuleSpec] = &[
108110
(
109111
"traits",
110112
"core",
111113
&[
112-
("Problem", "trait", "Core trait for all computational problems"),
114+
(
115+
"Problem",
116+
"trait",
117+
"Core trait for all computational problems",
118+
),
113119
(
114120
"OptimizationProblem",
115121
"trait",
@@ -140,11 +146,7 @@ fn main() {
140146
"variant",
141147
"core",
142148
&[
143-
(
144-
"VariantParam",
145-
"trait",
146-
"Trait for variant parameter types",
147-
),
149+
("VariantParam", "trait", "Trait for variant parameter types"),
148150
(
149151
"CastToParent",
150152
"trait",
@@ -188,28 +190,16 @@ fn main() {
188190
"struct",
189191
"Global graph of all registered reductions",
190192
),
191-
(
192-
"ReductionEntry",
193-
"struct",
194-
"A single registered reduction",
195-
),
196-
(
197-
"VariantEntry",
198-
"struct",
199-
"A registered problem variant",
200-
),
193+
("ReductionEntry", "struct", "A single registered reduction"),
194+
("VariantEntry", "struct", "A registered problem variant"),
201195
],
202196
),
203197
(
204198
"solvers",
205199
"solver",
206200
&[
207201
("BruteForce", "struct", "Exhaustive search solver"),
208-
(
209-
"ILPSolver",
210-
"struct",
211-
"Integer linear programming solver",
212-
),
202+
("ILPSolver", "struct", "Integer linear programming solver"),
213203
(
214204
"Solver",
215205
"trait",

problemreductions-cli/src/cli.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Flags by problem type:
265265
BicliqueCover --left, --right, --biedges, --k
266266
BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k
267267
BiconnectivityAugmentation --graph, --potential-edges, --budget [--num-vertices]
268+
PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices]
268269
BMF --matrix (0/1), --rank
269270
ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k
270271
ConsecutiveOnesSubmatrix --matrix (0/1), --k
@@ -612,6 +613,9 @@ pub struct CreateArgs {
612613
/// Total budget for selected potential edges
613614
#[arg(long)]
614615
pub budget: Option<String>,
616+
/// Maximum cycle length L for PartialFeedbackEdgeSet
617+
#[arg(long)]
618+
pub max_cycle_length: Option<usize>,
615619
/// Candidate weighted arcs for StrongConnectivityAugmentation (e.g., 2>0:1,2>1:3)
616620
#[arg(long)]
617621
pub candidate_arcs: Option<String>,
@@ -916,6 +920,44 @@ mod tests {
916920
assert!(help.contains("--budget"));
917921
}
918922

923+
#[test]
924+
fn test_create_parses_partial_feedback_edge_set_flags() {
925+
let cli = Cli::parse_from([
926+
"pred",
927+
"create",
928+
"PartialFeedbackEdgeSet",
929+
"--graph",
930+
"0-1,1-2,2-0",
931+
"--budget",
932+
"1",
933+
"--max-cycle-length",
934+
"3",
935+
]);
936+
937+
let Commands::Create(args) = cli.command else {
938+
panic!("expected create command");
939+
};
940+
941+
assert_eq!(args.problem.as_deref(), Some("PartialFeedbackEdgeSet"));
942+
assert_eq!(args.graph.as_deref(), Some("0-1,1-2,2-0"));
943+
assert_eq!(args.budget.as_deref(), Some("1"));
944+
assert_eq!(args.max_cycle_length, Some(3));
945+
}
946+
947+
#[test]
948+
fn test_create_help_mentions_partial_feedback_edge_set_flags() {
949+
let cmd = Cli::command();
950+
let create = cmd.find_subcommand("create").expect("create subcommand");
951+
let help = create
952+
.get_after_help()
953+
.expect("create after_help")
954+
.to_string();
955+
956+
assert!(help.contains("PartialFeedbackEdgeSet"));
957+
assert!(help.contains("--budget"));
958+
assert!(help.contains("--max-cycle-length"));
959+
}
960+
919961
#[test]
920962
fn test_create_help_mentions_stacker_crane_flags() {
921963
let cmd = Cli::command();

problemreductions-cli/src/commands/create.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
142142
&& args.candidate_arcs.is_none()
143143
&& args.potential_edges.is_none()
144144
&& args.budget.is_none()
145+
&& args.max_cycle_length.is_none()
145146
&& args.deadlines.is_none()
146147
&& args.lengths.is_none()
147148
&& args.precedence_pairs.is_none()
@@ -587,6 +588,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
587588
"BiconnectivityAugmentation" => {
588589
"--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5"
589590
}
591+
"PartialFeedbackEdgeSet" => {
592+
"--graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4"
593+
}
590594
"Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"",
591595
"NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"",
592596
"QuantifiedBooleanFormulas" => {
@@ -1351,6 +1355,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
13511355
)
13521356
}
13531357

1358+
// Partial Feedback Edge Set
1359+
"PartialFeedbackEdgeSet" => {
1360+
let usage = "Usage: pred create PartialFeedbackEdgeSet --graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4";
1361+
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
1362+
let budget = args
1363+
.budget
1364+
.as_deref()
1365+
.ok_or_else(|| {
1366+
anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}")
1367+
})?
1368+
.parse::<usize>()
1369+
.map_err(|e| {
1370+
anyhow::anyhow!(
1371+
"Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}"
1372+
)
1373+
})?;
1374+
let max_cycle_length = args.max_cycle_length.ok_or_else(|| {
1375+
anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}")
1376+
})?;
1377+
(
1378+
ser(PartialFeedbackEdgeSet::new(graph, budget, max_cycle_length))?,
1379+
resolved_variant.clone(),
1380+
)
1381+
}
1382+
13541383
// Bounded Component Spanning Forest
13551384
"BoundedComponentSpanningForest" => {
13561385
let usage = "Usage: pred create 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";
@@ -7200,6 +7229,7 @@ mod tests {
72007229
distance_matrix: None,
72017230
potential_edges: None,
72027231
budget: None,
7232+
max_cycle_length: None,
72037233
candidate_arcs: None,
72047234
deadlines: None,
72057235
precedence_pairs: None,
@@ -7258,6 +7288,13 @@ mod tests {
72587288
assert!(!all_data_flags_empty(&args));
72597289
}
72607290

7291+
#[test]
7292+
fn test_all_data_flags_empty_treats_max_cycle_length_as_input() {
7293+
let mut args = empty_args();
7294+
args.max_cycle_length = Some(4);
7295+
assert!(!all_data_flags_empty(&args));
7296+
}
7297+
72617298
#[test]
72627299
fn test_all_data_flags_empty_treats_homologous_pairs_as_input() {
72637300
let mut args = empty_args();
@@ -7458,6 +7495,61 @@ mod tests {
74587495
std::fs::remove_file(output_path).ok();
74597496
}
74607497

7498+
#[test]
7499+
fn test_create_partial_feedback_edge_set_json() {
7500+
use problemreductions::models::graph::PartialFeedbackEdgeSet;
7501+
7502+
let mut args = empty_args();
7503+
args.problem = Some("PartialFeedbackEdgeSet".to_string());
7504+
args.graph = Some("0-1,1-2,2-0".to_string());
7505+
args.budget = Some("1".to_string());
7506+
args.max_cycle_length = Some(3);
7507+
7508+
let output_path =
7509+
std::env::temp_dir().join("pred_test_create_partial_feedback_edge_set.json");
7510+
let out = OutputConfig {
7511+
output: Some(output_path.clone()),
7512+
quiet: true,
7513+
json: false,
7514+
auto_json: false,
7515+
};
7516+
7517+
create(&args, &out).unwrap();
7518+
7519+
let content = std::fs::read_to_string(&output_path).unwrap();
7520+
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
7521+
assert_eq!(json["type"], "PartialFeedbackEdgeSet");
7522+
assert_eq!(json["data"]["budget"], 1);
7523+
assert_eq!(json["data"]["max_cycle_length"], 3);
7524+
7525+
let problem: PartialFeedbackEdgeSet<SimpleGraph> =
7526+
serde_json::from_value(json["data"].clone()).unwrap();
7527+
assert_eq!(problem.num_vertices(), 3);
7528+
assert_eq!(problem.num_edges(), 3);
7529+
assert_eq!(problem.budget(), 1);
7530+
assert_eq!(problem.max_cycle_length(), 3);
7531+
7532+
std::fs::remove_file(output_path).ok();
7533+
}
7534+
7535+
#[test]
7536+
fn test_create_partial_feedback_edge_set_requires_max_cycle_length() {
7537+
let mut args = empty_args();
7538+
args.problem = Some("PartialFeedbackEdgeSet".to_string());
7539+
args.graph = Some("0-1,1-2,2-0".to_string());
7540+
args.budget = Some("1".to_string());
7541+
7542+
let out = OutputConfig {
7543+
output: None,
7544+
quiet: true,
7545+
json: false,
7546+
auto_json: false,
7547+
};
7548+
7549+
let err = create(&args, &out).unwrap_err().to_string();
7550+
assert!(err.contains("PartialFeedbackEdgeSet requires --max-cycle-length"));
7551+
}
7552+
74617553
#[test]
74627554
fn test_create_ensemble_computation_json() {
74637555
let mut args = empty_args();

src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ pub mod prelude {
6161
MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet,
6262
MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet,
6363
MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching,
64-
MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2,
65-
PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman,
66-
ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman,
67-
UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow,
64+
MultipleCopyFileAllocation, OptimalLinearArrangement, PartialFeedbackEdgeSet,
65+
PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow,
66+
RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs,
67+
TravelingSalesman, UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow,
6868
};
6969
pub use crate::models::misc::{
7070
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation,

src/models/graph/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
//! - [`BottleneckTravelingSalesman`]: Hamiltonian cycle minimizing the maximum selected edge weight
3535
//! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs
3636
//! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K)
37+
//! - [`PartialFeedbackEdgeSet`]: Remove at most K edges to hit every short cycle
3738
//! - [`RootedTreeArrangement`]: Rooted-tree embedding with bounded total edge stretch
3839
//! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs
3940
//! - [`MinMaxMulticenter`]: Min-max multicenter (vertex p-center, satisfaction)
@@ -94,6 +95,7 @@ pub(crate) mod mixed_chinese_postman;
9495
pub(crate) mod multiple_choice_branching;
9596
pub(crate) mod multiple_copy_file_allocation;
9697
pub(crate) mod optimal_linear_arrangement;
98+
pub(crate) mod partial_feedback_edge_set;
9799
pub(crate) mod partition_into_paths_of_length_2;
98100
pub(crate) mod partition_into_triangles;
99101
pub(crate) mod path_constrained_network_flow;
@@ -149,6 +151,7 @@ pub use mixed_chinese_postman::MixedChinesePostman;
149151
pub use multiple_choice_branching::MultipleChoiceBranching;
150152
pub use multiple_copy_file_allocation::MultipleCopyFileAllocation;
151153
pub use optimal_linear_arrangement::OptimalLinearArrangement;
154+
pub use partial_feedback_edge_set::PartialFeedbackEdgeSet;
152155
pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2;
153156
pub use partition_into_triangles::PartitionIntoTriangles;
154157
pub use path_constrained_network_flow::PathConstrainedNetworkFlow;
@@ -219,6 +222,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
219222
specs.extend(integral_flow_homologous_arcs::canonical_model_example_specs());
220223
specs.extend(minimum_feedback_arc_set::canonical_model_example_specs());
221224
specs.extend(optimal_linear_arrangement::canonical_model_example_specs());
225+
specs.extend(partial_feedback_edge_set::canonical_model_example_specs());
222226
specs.extend(mixed_chinese_postman::canonical_model_example_specs());
223227
specs.extend(subgraph_isomorphism::canonical_model_example_specs());
224228
specs

0 commit comments

Comments
 (0)