Skip to content

Commit 4f83030

Browse files
GiggleLiuclaudeisPANN
authored
Fix #714: [Model] KClique (#715)
* Add plan for #714: [Model] KClique * Implement #714: [Model] KClique * chore: remove plan file after implementation * chore: add gh retry logic and enforce read-only agentic tests in review-pipeline - pipeline_board.py: retry transient gh CLI failures (3 attempts with backoff) - review-pipeline SKILL.md: explicitly instruct agentic-test subagent to skip fix mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn>
1 parent accbb66 commit 4f83030

11 files changed

Lines changed: 434 additions & 10 deletions

File tree

.claude/skills/review-pipeline/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ Invoke `/review-quality` (file: `.claude/skills/review-quality/SKILL.md`) with t
186186
- Classify as: `confirmed` / `not reproducible in current worktree`
187187
- For confirmed issues, note severity and recommended fix
188188
189-
**Do NOT fix any issues.** Only report them.
189+
**Do NOT fix any issues.** Only report them. When dispatching the agentic-test subagent, explicitly instruct it: "This is a read-only review run. Do NOT offer to fix issues, do NOT select option (a) 'Review together and fix', and do NOT modify any files. Report findings only and stop after generating the report."
190190
191191
### 2. Compose Combined Review Comment
192192

docs/paper/reductions.typ

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"IsomorphicSpanningTree": [Isomorphic Spanning Tree],
7878
"KthBestSpanningTree": [Kth Best Spanning Tree],
7979
"KColoring": [$k$-Coloring],
80+
"KClique": [$k$-Clique],
8081
"MinimumDominatingSet": [Minimum Dominating Set],
8182
"MaximumMatching": [Maximum Matching],
8283
"TravelingSalesman": [Traveling Salesman],
@@ -1431,6 +1432,32 @@ is feasible: each set induces a connected subgraph, the component weights are $2
14311432
]
14321433
]
14331434
}
1435+
#{
1436+
let x = load-model-example("KClique")
1437+
let nv = graph-num-vertices(x.instance)
1438+
let ne = graph-num-edges(x.instance)
1439+
let edges = x.instance.graph.edges
1440+
let k = x.instance.k
1441+
let sol = (config: x.optimal_config, metric: x.optimal_value)
1442+
let K = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
1443+
let clique-edges = edges.filter(e => K.contains(e.at(0)) and K.contains(e.at(1)))
1444+
[
1445+
#problem-def("KClique")[
1446+
Given an undirected graph $G = (V, E)$ and an integer $k$, determine whether there exists a subset $K subset.eq V$ with $|K| >= k$ such that every pair of distinct vertices in $K$ is adjacent.
1447+
][
1448+
$k$-Clique is the classical decision version of Clique, one of Karp's original NP-complete problems @karp1972 and listed as GT19 in Garey and Johnson @garey1979. Unlike Maximum Clique, the threshold $k$ is part of the input, so this formulation is the natural target for decision-to-decision reductions such as $3$SAT $arrow.r$ Clique. The best known exact algorithm matches Maximum Clique via the complement reduction to Maximum Independent Set and runs in $O^*(1.1996^n)$ @xiao2017.
1449+
1450+
*Example.* Consider the house graph $G$ with $n = #nv$ vertices, $|E| = #ne$ edges, and threshold $k = #k$. The set $K = {#K.map(i => $v_#i$).join(", ")}$ is a valid witness because all three pairs #clique-edges.map(((u, v)) => $(v_#u, v_#v)$).join(", ") are edges, so $|K| = 3 >= #k$ and this is a YES instance. This witness is unique, and no $4$-clique exists because every vertex outside $K$ misses at least one edge to the other selected vertices.
1451+
1452+
#figure({
1453+
let hg = house-graph()
1454+
draw-edge-highlight(hg.vertices, hg.edges, clique-edges, K)
1455+
},
1456+
caption: [The house graph with satisfying witness $K = {#K.map(i => $v_#i$).join(", ")}$ for $k = #k$. The selected vertices and their internal clique edges are highlighted in blue.],
1457+
) <fig:house-kclique>
1458+
]
1459+
]
1460+
}
14341461
#{
14351462
let x = load-model-example("MaximumClique")
14361463
let nv = graph-num-vertices(x.instance)

problemreductions-cli/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ Flags by problem type:
224224
QUBO --matrix
225225
SpinGlass --graph, --couplings, --fields
226226
KColoring --graph, --k
227+
KClique --graph, --k
227228
MinimumMultiwayCut --graph, --terminals, --edge-weights
228229
PartitionIntoTriangles --graph
229230
GraphPartitioning --graph

problemreductions-cli/src/commands/create.rs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
385385
Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5",
386386
_ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1",
387387
},
388+
"KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3",
388389
"GraphPartitioning" => "--graph 0-1,1-2,2-3,0-2,1-3,0-3",
389390
"GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5",
390391
"MinimumCutIntoBoundedSets" => {
@@ -1376,6 +1377,13 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
13761377
util::ser_kcoloring(graph, k)?
13771378
}
13781379

1380+
"KClique" => {
1381+
let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3";
1382+
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
1383+
let k = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?;
1384+
(ser(KClique::new(graph, k))?, resolved_variant.clone())
1385+
}
1386+
13791387
// SAT
13801388
"Satisfiability" => {
13811389
let num_vars = args.num_vars.ok_or_else(|| {
@@ -3403,6 +3411,21 @@ fn ser<T: Serialize>(problem: T) -> Result<serde_json::Value> {
34033411
util::ser(problem)
34043412
}
34053413

3414+
fn parse_kclique_threshold(
3415+
k_flag: Option<usize>,
3416+
num_vertices: usize,
3417+
usage: &str,
3418+
) -> Result<usize> {
3419+
let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires --k\n\n{usage}"))?;
3420+
if k == 0 {
3421+
bail!("KClique: --k must be positive");
3422+
}
3423+
if k > num_vertices {
3424+
bail!("KClique: k must be <= graph num_vertices");
3425+
}
3426+
Ok(k)
3427+
}
3428+
34063429
fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
34073430
util::variant_map(pairs)
34083431
}
@@ -4450,6 +4473,21 @@ fn create_random(
44504473
}
44514474
}
44524475

4476+
"KClique" => {
4477+
let edge_prob = args.edge_prob.unwrap_or(0.5);
4478+
if !(0.0..=1.0).contains(&edge_prob) {
4479+
bail!("--edge-prob must be between 0.0 and 1.0");
4480+
}
4481+
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
4482+
let usage =
4483+
"Usage: pred create KClique --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3";
4484+
let k = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?;
4485+
(
4486+
ser(KClique::new(graph, k))?,
4487+
variant_map(&[("graph", "SimpleGraph")]),
4488+
)
4489+
}
4490+
44534491
// MinimumCutIntoBoundedSets (graph + edge weights + s/t/B/K)
44544492
"MinimumCutIntoBoundedSets" => {
44554493
let edge_prob = args.edge_prob.unwrap_or(0.5);
@@ -4702,7 +4740,7 @@ fn create_random(
47024740
_ => bail!(
47034741
"Random generation is not supported for {canonical}. \
47044742
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
4705-
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman, \
4743+
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \
47064744
SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
47074745
),
47084746
};
@@ -5349,4 +5387,69 @@ mod tests {
53495387
let err = create(&args, &out).unwrap_err().to_string();
53505388
assert!(err.contains("out of bounds for left partition size 4"));
53515389
}
5390+
5391+
#[test]
5392+
fn test_create_kclique() {
5393+
use crate::dispatch::ProblemJsonOutput;
5394+
use problemreductions::models::graph::KClique;
5395+
5396+
let mut args = empty_args();
5397+
args.problem = Some("KClique".to_string());
5398+
args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string());
5399+
args.k = Some(3);
5400+
5401+
let output_path =
5402+
std::env::temp_dir().join(format!("kclique-create-{}.json", std::process::id()));
5403+
let out = OutputConfig {
5404+
output: Some(output_path.clone()),
5405+
quiet: true,
5406+
json: false,
5407+
auto_json: false,
5408+
};
5409+
5410+
create(&args, &out).unwrap();
5411+
5412+
let json = std::fs::read_to_string(&output_path).unwrap();
5413+
let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap();
5414+
assert_eq!(created.problem_type, "KClique");
5415+
assert_eq!(
5416+
created.variant.get("graph").map(String::as_str),
5417+
Some("SimpleGraph")
5418+
);
5419+
5420+
let problem: KClique<SimpleGraph> = serde_json::from_value(created.data).unwrap();
5421+
assert_eq!(problem.k(), 3);
5422+
assert_eq!(problem.num_vertices(), 5);
5423+
assert!(problem.evaluate(&[0, 0, 1, 1, 1]));
5424+
5425+
let _ = std::fs::remove_file(output_path);
5426+
}
5427+
5428+
#[test]
5429+
fn test_create_kclique_requires_valid_k() {
5430+
let mut args = empty_args();
5431+
args.problem = Some("KClique".to_string());
5432+
args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string());
5433+
args.k = None;
5434+
5435+
let out = OutputConfig {
5436+
output: None,
5437+
quiet: true,
5438+
json: false,
5439+
auto_json: false,
5440+
};
5441+
5442+
let err = create(&args, &out).unwrap_err();
5443+
assert!(
5444+
err.to_string().contains("KClique requires --k"),
5445+
"unexpected error: {err}"
5446+
);
5447+
5448+
args.k = Some(6);
5449+
let err = create(&args, &out).unwrap_err();
5450+
assert!(
5451+
err.to_string().contains("k must be <= graph num_vertices"),
5452+
"unexpected error: {err}"
5453+
);
5454+
}
53525455
}

problemreductions-cli/src/mcp/tools.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::util;
22
use problemreductions::models::algebraic::QUBO;
33
use problemreductions::models::formula::{CNFClause, Satisfiability};
44
use problemreductions::models::graph::{
5-
MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet,
5+
KClique, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet,
66
MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman,
77
};
88
use problemreductions::models::misc::Factoring;
@@ -68,7 +68,7 @@ pub struct CreateProblemParams {
6868
)]
6969
pub problem_type: String,
7070
#[schemars(
71-
description = "Problem parameters as JSON object. Graph problems: {\"edges\": \"0-1,1-2\", \"weights\": \"1,2,3\"}. SAT: {\"num_vars\": 3, \"clauses\": \"1,2;-1,3\"}. QUBO: {\"matrix\": \"1,0.5;0.5,2\"}. KColoring: {\"edges\": \"0-1,1-2\", \"k\": 3}. Factoring: {\"target\": 15, \"bits_m\": 4, \"bits_n\": 4}. Random graph: {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}. Geometry graphs (use with MIS/KingsSubgraph etc.): {\"positions\": \"0,0;1,0;1,1\"}. UnitDiskGraph: {\"positions\": \"0.0,0.0;1.0,0.0\", \"radius\": 1.5}"
71+
description = "Problem parameters as JSON object. Graph problems: {\"edges\": \"0-1,1-2\", \"weights\": \"1,2,3\"}. SAT: {\"num_vars\": 3, \"clauses\": \"1,2;-1,3\"}. QUBO: {\"matrix\": \"1,0.5;0.5,2\"}. KColoring: {\"edges\": \"0-1,1-2\", \"k\": 3}. KClique: {\"edges\": \"0-1,0-2,1-3,2-3,2-4,3-4\", \"k\": 3}. Factoring: {\"target\": 15, \"bits_m\": 4, \"bits_n\": 4}. Random graph: {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}. Geometry graphs (use with MIS/KingsSubgraph etc.): {\"positions\": \"0,0;1,0;1,1\"}. UnitDiskGraph: {\"positions\": \"0.0,0.0;1.0,0.0\", \"radius\": 1.5}"
7272
)]
7373
pub params: serde_json::Value,
7474
}
@@ -406,6 +406,16 @@ impl McpServer {
406406
util::ser_kcoloring(graph, k)?
407407
}
408408

409+
"KClique" => {
410+
let (graph, _) = parse_graph_from_params(params)?;
411+
let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize);
412+
let k = parse_kclique_threshold(k_flag, graph.num_vertices())?;
413+
(
414+
ser(KClique::new(graph, k))?,
415+
variant_map(&[("graph", "SimpleGraph")]),
416+
)
417+
}
418+
409419
// SAT
410420
"Satisfiability" => {
411421
let num_vars = params
@@ -613,6 +623,22 @@ impl McpServer {
613623
util::validate_k_param(resolved_variant, k_flag, Some(3), "KColoring")?;
614624
util::ser_kcoloring(graph, k)?
615625
}
626+
"KClique" => {
627+
let edge_prob = params
628+
.get("edge_prob")
629+
.and_then(|v| v.as_f64())
630+
.unwrap_or(0.5);
631+
if !(0.0..=1.0).contains(&edge_prob) {
632+
anyhow::bail!("edge_prob must be between 0.0 and 1.0");
633+
}
634+
let graph = util::create_random_graph(num_vertices, edge_prob, seed);
635+
let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize);
636+
let k = parse_kclique_threshold(k_flag, graph.num_vertices())?;
637+
(
638+
ser(KClique::new(graph, k))?,
639+
variant_map(&[("graph", "SimpleGraph")]),
640+
)
641+
}
616642
"MinimumSumMulticenter" => {
617643
let edge_prob = params
618644
.get("edge_prob")
@@ -644,7 +670,7 @@ impl McpServer {
644670
_ => anyhow::bail!(
645671
"Random generation is not supported for {}. \
646672
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
647-
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, \
673+
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, \
648674
TravelingSalesman, MinimumSumMulticenter)",
649675
canonical
650676
),
@@ -1231,6 +1257,17 @@ fn parse_graph_from_params(params: &serde_json::Value) -> anyhow::Result<(Simple
12311257
Ok((SimpleGraph::new(num_vertices, edges), num_vertices))
12321258
}
12331259

1260+
fn parse_kclique_threshold(k_flag: Option<usize>, num_vertices: usize) -> anyhow::Result<usize> {
1261+
let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires 'k'"))?;
1262+
if k == 0 {
1263+
anyhow::bail!("KClique: 'k' must be positive");
1264+
}
1265+
if k > num_vertices {
1266+
anyhow::bail!("KClique: k must be <= graph num_vertices");
1267+
}
1268+
Ok(k)
1269+
}
1270+
12341271
/// Parse `weights` field from JSON params as vertex weights (i32), defaulting to all 1s.
12351272
fn parse_vertex_weights_from_params(
12361273
params: &serde_json::Value,

scripts/pipeline_board.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,38 @@
6161
FAILURE_LABELS = {"PoorWritten", "Wrong", "Trivial", "Useless"}
6262

6363

64-
def run_gh(*args: str) -> str:
65-
return subprocess.check_output(["gh", *args], text=True)
64+
def run_gh(*args: str, retries: int = 3, retry_delay: float = 5.0) -> str:
65+
"""Run a ``gh`` CLI command, retrying on transient failures.
66+
67+
The ``gh project`` subcommands occasionally fail with cryptic errors
68+
like "unknown owner type" due to transient API issues or token
69+
refresh races (see cli/cli#7985, cli/cli#8885). Retrying after a
70+
short delay resolves these reliably.
71+
"""
72+
last_exc: subprocess.CalledProcessError | None = None
73+
for attempt in range(retries):
74+
try:
75+
return subprocess.check_output(
76+
["gh", *args], text=True, stderr=subprocess.PIPE,
77+
)
78+
except subprocess.CalledProcessError as exc:
79+
last_exc = exc
80+
stderr = (exc.stderr or "").strip()
81+
if attempt < retries - 1:
82+
print(
83+
f"[run_gh] attempt {attempt + 1}/{retries} failed "
84+
f"(rc={exc.returncode}, stderr={stderr!r}), "
85+
f"retrying in {retry_delay}s…",
86+
file=sys.stderr,
87+
)
88+
time.sleep(retry_delay)
89+
else:
90+
print(
91+
f"[run_gh] all {retries} attempts failed "
92+
f"(stderr={stderr!r})",
93+
file=sys.stderr,
94+
)
95+
raise last_exc # type: ignore[misc]
6696

6797

6898
def _graphql_board_query(project_id: str, page_size: int, cursor: str | None) -> str:

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub mod prelude {
5050
pub use crate::models::graph::{
5151
BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation,
5252
BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex,
53-
GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree,
53+
GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique,
5454
KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree,
5555
StrongConnectivityAugmentation, SubgraphIsomorphism,
5656
};

0 commit comments

Comments
 (0)