Repository navigation
Expand file tree
/
Copy pathcli.rs
More file actions
597 lines (540 loc) · 21.4 KB
/
Copy pathcli.rs
File metadata and controls
597 lines (540 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use problemreductions::registry::ProblemCategory;
use std::ffi::OsString;
use std::path::PathBuf;
pub use crate::create_args::CreateArgs;
#[derive(Parser)]
#[command(
name = "pred",
about = "Explore NP-hard problem reductions",
version,
after_help = "\
Typical workflow:
pred create MIS --graph 0-1,1-2,2-3 -o problem.json
pred solve problem.json
pred evaluate problem.json --config '[true,false,true,false]'
Piping (use - to read from stdin):
pred create MIS --graph 0-1,1-2 | pred solve -
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 --graph 0-1,1-2 | pred evaluate - --config '[true,false,true]'
pred create MIS --graph 0-1,1-2 | pred reduce - --via route.json
JSON output (any command):
pred list --json # JSON to stdout
pred show MIS --json | jq '.' # pipe to jq
Use `pred <command> --help` for detailed usage of each command.
Use `pred list` to see all available problem types.
Enable tab completion:
eval \"$(pred completions)\" # add to ~/.bashrc or ~/.zshrc"
)]
pub struct Cli {
/// Output file path (implies JSON output)
#[arg(long, short, global = true)]
pub output: Option<PathBuf>,
/// Suppress informational messages on stderr
#[arg(long, short, global = true)]
pub quiet: bool,
/// Output JSON to stdout instead of human-readable text
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Commands,
}
impl Cli {
pub fn try_parse() -> Result<Self, clap::Error> {
Self::try_parse_from(std::env::args_os())
}
pub fn try_parse_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
// The discovery command treats the problem spec as an external subcommand,
// so it can capture the selected model without registering the whole catalog.
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
let command = <Self as CommandFactory>::command();
let discovery_matches = command.clone().try_get_matches_from(args.clone())?;
let selected = discovery_matches
.subcommand_matches("create")
.and_then(|matches| matches.subcommand_name());
let mut matches = if let Some(selected) = selected {
crate::create_args::command_for_selected_problem(command, selected)?
.try_get_matches_from(args)?
} else {
discovery_matches
};
Self::from_arg_matches_mut(&mut matches)
}
}
#[derive(Subcommand)]
pub enum Commands {
/// Browse registered problem types (or reduction rules with --rules)
#[command(after_help = "\
Examples:
pred list # show catalog summary and categories
pred list matching # search names and aliases
pred list --category graph # list graph problems
pred list --all # list every problem compactly
pred list --rules --all # list every reduction rule
pred list -o problems.json # save as JSON")]
List {
/// Case-insensitive substring to search in names and aliases
query: Option<String>,
/// List reduction rules instead of problem types
#[arg(long)]
rules: bool,
/// Restrict problems to a model category such as graph, set, or misc
#[arg(long, conflicts_with = "rules")]
category: Option<ProblemCategory>,
/// List the complete catalog instead of the summary
#[arg(long)]
all: bool,
/// Include per-variant complexity, rule counts, or rule parameter contracts
#[arg(long)]
verbose: bool,
},
/// Show details for a problem type or variant (fields, reductions, complexity)
#[command(after_help = "\
Examples:
pred show MIS # all variants for MIS
pred show MIS/UnitDiskGraph # specific variant
pred show MIS/UnitDiskGraph/i64 # fully qualified variant
pred show KSAT/K3 # KSatisfiability with K=3
Use `pred list` to see all available problem types and variants.")]
Show {
/// Problem name or variant (e.g., MIS, MIS/UnitDiskGraph, KSAT/K3)
#[arg(value_parser = crate::problem_name::ProblemNameParser)]
problem: String,
},
/// Explore problems that reduce TO this one (incoming neighbors)
#[command(after_help = "\
Examples:
pred to MIS # what reduces to MIS? (1 hop)
pred to MIS --hops 2 # 2-hop incoming neighbors
pred to MIS -o out.json # save as JSON
Use `pred from <problem>` for outgoing neighbors (what this reduces to).")]
To {
/// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
#[arg(value_parser = crate::problem_name::ProblemNameParser)]
problem: String,
/// Number of hops to explore
#[arg(long, default_value = "1")]
hops: usize,
},
/// Explore problems this reduces to, starting FROM it (outgoing neighbors)
#[command(after_help = "\
Examples:
pred from MIS # what does MIS reduce to? (1 hop)
pred from MIS --hops 2 # 2-hop outgoing neighbors
pred from MIS -o out.json # save as JSON
Use `pred to <problem>` for incoming neighbors (what reduces to this).")]
From {
/// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
#[arg(value_parser = crate::problem_name::ProblemNameParser)]
problem: String,
/// Number of hops to explore
#[arg(long, default_value = "1")]
hops: usize,
},
/// Find reduction paths between two problems
#[command(after_help = "\
Examples:
pred path MIS QUBO # inspect reduction paths
pred path MIS Clique mis.json # execute paths on an instance
pred path MIS QUBO --limit 50 # inspect the first 50 paths
pred path MIS QUBO --limit all # inspect up to 999 paths
pred path MIS QUBO -o paths.json # save the path set
Use `pred list` to see available problems.")]
Path {
/// Source problem (e.g., MIS, MIS/UnitDiskGraph)
#[arg(value_parser = crate::problem_name::ProblemNameParser)]
source: String,
/// Target problem (e.g., QUBO)
#[arg(value_parser = crate::problem_name::ProblemNameParser)]
target: String,
/// Number of paths to inspect (1-999, or "all" as an alias for 999)
#[arg(
long,
default_value = "20",
value_parser = crate::commands::graph::parse_path_limit
)]
limit: usize,
/// Source problem instance JSON. When present, execute every returned path and measure each constructed problem.
instance: Option<std::path::PathBuf>,
/// Compare chosen polynomial certificates in the batch-relative generic order.
#[arg(long, conflicts_with = "instance")]
compare: bool,
/// Show detailed pair verdicts, per-field proofs, and counterexample curves.
#[arg(long, requires = "compare")]
proofs: bool,
},
/// Export the reduction graph to JSON
#[command(after_help = "\
Examples:
pred export-graph # print to stdout
pred export-graph -o reduction_graph.json # save to file")]
ExportGraph,
/// Create a problem instance and save as JSON
Create(Box<CreateArgs>),
/// Evaluate a configuration against a problem instance JSON file
Evaluate(EvaluateArgs),
/// Reduce a problem instance to a target type
Reduce(ReduceArgs),
/// Inspect a problem JSON or reduction bundle
#[command(after_help = "\
Examples:
pred inspect problem.json
pred inspect bundle.json
pred create MIS --graph 0-1,1-2 | pred inspect -")]
Inspect(InspectArgs),
/// Solve a problem instance
Solve(SolveArgs),
/// Recover a source configuration or value through the reduction rules
#[command(after_help = "\
Examples:
pred extract bundle.json --config '[true,false]'
pred extract bundle.json --config '[true,false]' -o source.json
pred extract bundle.json --value 2
pred extract - --config '[true,false]' < bundle.json
--config calls the rules' solution mapping; --value calls their aggregate mapping.
Supply raw JSON of the completed target aggregate for --value: 2, true, or null.
Do not use wrapper syntax such as Min(2).
Extraction does not solve the target or prove that the supplied value is optimal.")]
Extract(ExtractArgs),
/// Start MCP (Model Context Protocol) server for AI assistant integration
#[cfg(feature = "mcp")]
#[command(after_help = "\
Start a stdio-based MCP server that exposes problem reduction tools
to any MCP-compatible AI assistant.
Configuration:
Claude Code / Claude Desktop (.mcp.json or ~/.claude/mcp.json):
{ \"mcpServers\": { \"problemreductions\": {
\"command\": \"pred\", \"args\": [\"mcp\"] } } }
Cursor (.cursor/mcp.json):
{ \"mcpServers\": { \"problemreductions\": {
\"command\": \"pred\", \"args\": [\"mcp\"] } } }
Windsurf (~/.codeium/windsurf/mcp_config.json):
{ \"mcpServers\": { \"problemreductions\": {
\"command\": \"pred\", \"args\": [\"mcp\"] } } }
OpenCode (opencode.json):
{ \"mcp\": { \"problemreductions\": {
\"type\": \"local\", \"command\": [\"pred\", \"mcp\"] } } }
Test with MCP Inspector:
npx @modelcontextprotocol/inspector pred mcp")]
Mcp,
/// Print shell completions to stdout (auto-detects shell)
#[command(after_help = "\
Setup: add one line to your shell rc file:
# bash (~/.bashrc)
eval \"$(pred completions bash)\"
# zsh (~/.zshrc)
eval \"$(pred completions zsh)\"
# fish (~/.config/fish/config.fish)
pred completions fish | source")]
Completions {
/// Shell type (bash, zsh, fish, etc.). Auto-detected if omitted.
shell: Option<clap_complete::Shell>,
},
}
#[derive(Clone, Debug, ValueEnum)]
pub enum ExampleSide {
Source,
Target,
}
#[derive(clap::Args)]
#[command(after_help = "\
Examples:
pred solve problem.json # deterministic registered backend or fallback
pred solve problem.json --solver customized # require a problem-specific solver
pred solve problem.json --solver brute-force # brute-force (exhaustive search)
pred solve problem.json --solver ilp # require the registered fixed ILP pipeline
pred solve reduced.json # solve a reduction bundle
pred solve reduced.json -o solution.json # save result to file
pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin
pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 | pred solve - --solver brute-force
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 TwoDimensionalConsecutiveSets --alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\" | pred solve - --solver brute-force
pred solve problem.json --timeout 10 # abort after 10 seconds
Typical workflow:
pred create MIS --graph 0-1,1-2,2-3 -o problem.json
pred solve problem.json
Solve via explicit reduction:
pred reduce problem.json --via route.json -o reduced.json
pred solve reduced.json
Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`.
When given a bundle, the target is solved and the solution is mapped back to the source.
By default, solve deterministically selects the exact variant's registered customized
backend, then its fixed ILP pipeline, and otherwise brute force. `--solver ilp`
requires a registered ILP pipeline; it never searches the reduction graph.
ILP problems are solved with HiGHS.")]
pub struct SolveArgs {
/// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin.
pub input: PathBuf,
/// Solver override: customized, ilp, or brute-force. Omit for deterministic default dispatch.
#[arg(long)]
pub solver: Option<String>,
/// Timeout in seconds (0 = no limit)
#[arg(long, default_value = "0")]
pub timeout: i64,
}
#[derive(clap::Args)]
#[command(after_help = "\
Examples:
pred reduce problem.json --via path.json -o reduced.json
pred create MIS --graph 0-1,1-2 | pred reduce - --via path.json # read from stdin
Input: a problem JSON from `pred create`. Use - to read from stdin.
The --via file must be one explicit entry selected by the caller from `pred path` output.
Output is a reduction bundle with source, target, and path.
Use `pred solve reduced.json` to solve and map the solution back.")]
pub struct ReduceArgs {
/// Problem JSON file (from `pred create`). Use - for stdin.
pub input: PathBuf,
/// Explicit reduction route selected from a path-set entry.
#[arg(long, required = true)]
pub via: PathBuf,
/// Construct an aggregate-value path for recovery with pred extract --value.
#[arg(long)]
pub aggregate: bool,
}
#[derive(clap::Args)]
pub struct ExtractArgs {
/// Reduction bundle JSON (from pred reduce). Use - for stdin.
pub input: PathBuf,
/// Target problem configuration encoded as JSON.
#[arg(long, required_unless_present = "value", conflicts_with = "value")]
pub config: Option<String>,
/// Completed target aggregate encoded as JSON, passed to the rules' value mapping.
#[arg(long)]
pub value: Option<String>,
}
#[derive(clap::Args)]
pub struct InspectArgs {
/// Problem JSON file or reduction bundle. Use - for stdin.
pub input: PathBuf,
}
#[derive(clap::Args)]
#[command(after_help = "\
Examples:
pred evaluate problem.json --config '[true,false,true,false]'
pred evaluate problem.json --config '[true,false,true,false]' -o result.json
pred create MIS --graph 0-1,1-2 | pred evaluate - --config '[true,false,true]' # read from stdin
Input: a problem JSON from `pred create`. Use - to read from stdin.")]
pub struct EvaluateArgs {
/// Problem JSON file (from `pred create`). Use - for stdin.
pub input: PathBuf,
/// Problem solution encoded as JSON (for example, [true,false,true,false])
#[arg(long)]
pub config: String,
}
/// Print the after_help text for a subcommand on parse error.
///
/// Only matches the first line of the error message. Without this,
/// bare `pred` (no subcommand) would match "pred solve" in the
/// top-level workflow examples and incorrectly append the solve
/// subcommand's help text.
pub fn print_subcommand_help_hint(error_msg: &str) {
let first_line = error_msg.lines().next().unwrap_or("");
let subcmds = [
("pred solve", "solve"),
("pred reduce", "reduce"),
("pred extract", "extract"),
("pred create", "create"),
("pred evaluate", "evaluate"),
("pred inspect", "inspect"),
("pred path", "path"),
("pred show", "show"),
("pred to", "to"),
("pred from", "from"),
("pred export-graph", "export-graph"),
];
let cmd = Cli::command();
for (pattern, name) in subcmds {
if first_line.contains(pattern) {
if let Some(sub) = cmd.find_subcommand(name) {
if let Some(help) = sub.get_after_help() {
eprintln!("\n{help}");
}
}
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
#[test]
fn two_stage_create_parser_uses_bounded_stack() {
std::thread::Builder::new()
.stack_size(1024 * 1024)
.spawn(|| {
assert_eq!(
Cli::try_parse_from(["pred", "--version"])
.err()
.expect("version exits")
.kind(),
ErrorKind::DisplayVersion
);
Cli::try_parse_from(["pred", "list"]).expect("list parses");
Cli::try_parse_from(["pred", "create", "MIS", "--graph", "0-1"])
.expect("MIS parses");
Cli::try_parse_from([
"pred",
"create",
"SAT",
"--num-vars",
"2",
"--clauses",
"1,2",
])
.expect("SAT parses");
assert_eq!(
Cli::try_parse_from(["pred", "create", "MIS", "--clauses", "1,2"])
.err()
.expect("unrelated flag is rejected")
.kind(),
ErrorKind::UnknownArgument
);
assert_eq!(
Cli::try_parse_from([
"pred", "create", "KClique", "--graph", "0-1", "--k", "nope"
])
.err()
.expect("invalid numeric value is rejected by Clap")
.kind(),
ErrorKind::ValueValidation
);
Cli::try_parse_from([
"pred",
"create",
"ShortestCommonSupersequence",
"--strings",
"0,1;1,2",
])
.expect("derived SCS fields are not required as inputs");
assert_eq!(
Cli::try_parse_from([
"pred",
"create",
"LCS",
"--strings",
"0,1;1,2",
"--max-length",
"99",
])
.err()
.expect("derived LCS max_length is not exposed")
.kind(),
ErrorKind::UnknownArgument
);
assert!(Cli::try_parse_from([
"pred",
"create",
"ThreePartition",
"--sizes",
"1,1,1",
"--bound",
"9223372036854775808",
])
.is_err());
Cli::try_parse_from([
"pred",
"create",
"MinimumCodeGenerationOneRegister",
"--edges",
"0>1",
])
.expect("ordinary edges fields keep their schema-derived name");
Cli::command().debug_assert();
})
.expect("spawn parser thread")
.join()
.expect("parser thread completes");
}
#[test]
fn two_stage_create_parser_preserves_problem_and_variant_aliases() {
let cli = Cli::try_parse_from([
"pred",
"create",
"2SAT",
"--num-vars",
"3",
"--clauses",
"1,2",
])
.expect("variant alias parses");
let Commands::Create(args) = cli.command else {
panic!("expected create command");
};
assert_eq!(args.problem.as_deref(), Some("KSatisfiability/K2"));
assert_eq!(args.raw("num-vars"), Some("3"));
}
#[test]
fn canonical_problem_name_is_not_treated_as_an_alias() {
let cli =
Cli::try_parse_from(["pred", "create", "GraphPartitioning", "--graph", "0-1,2-3"])
.expect("canonical problem name parses");
let Commands::Create(args) = cli.command else {
panic!("expected create command");
};
assert_eq!(args.problem.as_deref(), Some("GraphPartitioning"));
}
#[test]
fn selected_create_parser_preserves_trailing_global_arguments() {
let cli = Cli::try_parse_from([
"pred",
"create",
"MIS",
"--graph",
"0-1",
"--output",
"problem.json",
"--json",
])
.expect("global arguments parse after the selected model");
assert_eq!(cli.output, Some(PathBuf::from("problem.json")));
assert!(cli.json);
}
#[test]
fn selected_create_parser_renders_model_help() {
let error = match Cli::try_parse_from(["pred", "create", "MIS", "--help"]) {
Ok(_) => panic!("help should exit after rendering the selected model command"),
Err(error) => error,
};
assert_eq!(error.kind(), ErrorKind::DisplayHelp);
let help = error.to_string();
assert!(help.contains("--graph"));
assert!(!help.contains("--clauses"));
}
#[test]
fn static_create_parser_has_no_registered_model_subcommands() {
let command = Cli::command();
let create = command
.find_subcommand("create")
.expect("create subcommand");
assert_eq!(create.get_subcommands().count(), 0);
assert!(create.is_allow_external_subcommands_set());
}
#[test]
fn extract_requires_exactly_one_mapping_input() {
assert!(Cli::try_parse_from([
"pred",
"extract",
"bundle.json",
"--config",
"[true,false]"
])
.is_ok());
assert!(Cli::try_parse_from(["pred", "extract", "bundle.json"]).is_err());
assert!(Cli::try_parse_from(["pred", "extract", "bundle.json", "--value", "2"]).is_ok());
assert!(Cli::try_parse_from([
"pred",
"extract",
"bundle.json",
"--config",
"[true,false]",
"--value",
"2"
])
.is_err());
}
}