From 18778b3489fdaf38892f6809d0b70df34672159a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 11:19:36 +0800 Subject: [PATCH 01/13] docs: add schema-driven create.rs refactor design spec Design for replacing the 11K-line create.rs with a schema-driven generic dispatch using existing registry factory functions. Targets ~73% line reduction by eliminating the 5,400-line match statement. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-schema-driven-create-design.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/plans/2026-04-05-schema-driven-create-design.md diff --git a/docs/plans/2026-04-05-schema-driven-create-design.md b/docs/plans/2026-04-05-schema-driven-create-design.md new file mode 100644 index 00000000..8ff9216f --- /dev/null +++ b/docs/plans/2026-04-05-schema-driven-create-design.md @@ -0,0 +1,284 @@ +# Schema-Driven `pred create` Refactor + +**Date:** 2026-04-05 +**Goal:** Replace the 11K-line `create.rs` with a schema-driven generic dispatch that uses the existing registry `factory` function, reducing the file by ~50%. + +## Problem + +`problemreductions-cli/src/commands/create.rs` is 11,049 lines. The bulk is a 5,400-line `match canonical { ... }` that manually builds JSON for each of 177 problems, plus 480 lines for `create_random`, plus 330 lines of lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`). + +The registry already has a `factory: fn(serde_json::Value) -> Result>` per variant that calls `serde_json::from_value()`. The 5,400 lines are manually doing what the factory can do generically. + +## Design + +### Phase 1: Align CLI Flags to Struct Field Names + +Rename ~20 CLI flags in `CreateArgs` so every flag name matches its problem struct field name via `snake_case → kebab-case`. This makes convention-based mapping 100% mechanical with zero exceptions. + +**Renames required:** + +| Current flag | Struct field | New flag | Problems affected | +|---|---|---|---| +| `--job-tasks` | `jobs` | `--jobs` | JobShopScheduling | +| `--source-string` | `source` | (keep, add alias `--source-string`) | StringToStringCorrection | +| `--target-string` | `target` | (keep, add alias `--target-string`) | StringToStringCorrection | +| `--sets` | `subsets` | `--subsets` | SetPacking, MinimumHittingSet, etc. (~8) | +| `--universe` | `universe_size` | `--universe-size` | SetBasis, Betweenness, etc. (~5) | +| `--arc-costs` | `arc_weights` / `arc_lengths` | `--arc-weights` / `--arc-lengths` | MixedChinesePostman, StackerCrane | +| `--deps` | `dependencies` | `--dependencies` | PrimeAttributeName | +| `--query` | `query_attribute` | `--query-attribute` | PrimeAttributeName | +| `--precedence-pairs` | `precedences` | `--precedences` (already has alias) | MinimumTardinessSequencing, etc. (~4) | +| `--sizes` (for lengths) | `lengths` | `--lengths` (already exists!) | MultiprocessorScheduling, etc. (~5) | +| `--n` (for num_tasks) | `num_tasks` | `--num-tasks` (already exists!) | TimetableDesign, etc. (~4) | +| `--potential-edges` | `potential_weights` | `--potential-weights` | BiconnectivityAugmentation | +| `--bound` (various) | `max_length` / `max_weight` / `bound_k` / `threshold` | match each field | ~6 problems | + +**Backward compat:** Add `#[arg(alias = "old-name")]` for renamed flags so existing scripts don't break. + +**Note on `--source`/`--sink`/`--target`:** These flags are shared across many problems with different field names (`source`, `source_vertex`, `target`, `sink`). For fields like `GeneralizedHex.target` (which currently uses `--sink`), we keep `--sink` as an alias after renaming. The `source`/`sink`/`target` flags already match field names for most graph problems. StringToStringCorrection's `source`/`target` fields conflict with the graph vertex `--source`/`--sink` flags, so we keep `--source-string`/`--target-string` as aliases while the field-matched flag takes precedence during schema dispatch. + +### Phase 2: Generic Type Parser Registry + +A small registry that maps resolved concrete type names to parse functions. These parse functions already exist — we're just organizing them for generic dispatch. + +```rust +/// Parse a CLI string value into a serde_json::Value based on the resolved concrete type. +fn parse_field_value( + concrete_type: &str, + field_name: &str, + raw: &str, + context: &CreateContext, // holds graph info for size validation +) -> Result +``` + +**Type dispatch table (~15-20 entries):** + +| Concrete type pattern | Parse strategy | Existing helper | +|---|---|---| +| `SimpleGraph` | edge list → `{num_vertices, edges}` | `parse_graph()` | +| `BipartiteGraph` | bipartite edge list | `parse_bipartite_graph()` | +| `KingsSubgraph` / `TriangularSubgraph` | positions → grid subgraph | `parse_grid_subgraph()` | +| `UnitDiskGraph` | positions + radius | `parse_unit_disk_graph()` | +| `DirectedGraph` | arc list → `{num_vertices, arcs}` | `parse_directed_graph()` | +| `MixedGraph` | graph + arcs | `parse_mixed_graph()` | +| `Vec` / `Vec` / `Vec` | comma-separated numbers | `parse_numeric_list::()` | +| `Vec` | auto-fill unit weights (length from context) | fill with `1`s | +| `Vec>` | semicolon-separated groups | `parse_nested_usize_list()` | +| `Vec<[usize; 3]>` | semicolon-separated triples | `parse_triple_list()` | +| `Vec` | semicolon-separated signed literals | `parse_clauses()` | +| `Vec<(usize, usize)>` | pair list (comma or `>` separated) | `parse_pair_list()` | +| `Vec>` | job-task format | `parse_job_shop_jobs()` | +| `usize` / `u64` / `i32` / `f64` | single number parse | `str::parse::()` | +| `One` (scalar unit weight) | skip field / default to `null` | (handled by serde default) | +| `bool` | "true"/"false" parse | `str::parse::()` | + +**Generic type resolution:** `FieldInfo.type_name` uses generic names ("G", "Vec"). Resolve using the variant map: +- `"G"` → look up `variant["graph"]` → `"SimpleGraph"` +- `"Vec"` → look up `variant["weight"]` → substitute `W` → `"Vec"` +- Concrete types like `"Vec"` or `"usize"` → use directly + +### Phase 3: Generic `create()` Function + +Replace the 5,400-line match with: + +```rust +pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { + // Existing: example path, ILP/CircuitSAT rejection, random path + if args.example.is_some() { return create_from_example(args, out); } + // ... resolve canonical name, variant ... + if args.random { return create_random(args, canonical, &resolved_variant, out); } + + // NEW: schema-driven path + let schema = find_schema(canonical) + .ok_or_else(|| anyhow!("No schema for {canonical}"))?; + let variant_entry = find_variant_entry(canonical, &resolved_variant)?; + + // Show help if no data flags provided + if all_data_flags_empty(args) { + print_schema_help(canonical, &schema, &resolved_variant)?; + std::process::exit(2); + } + + // Build JSON from schema fields + let mut json_map = serde_json::Map::new(); + let mut context = CreateContext::default(); + + for field in &schema.fields { + let flag_name = field.name.replace('_', "-"); // convention + let raw_value = get_flag_value(args, &flag_name); + let concrete_type = resolve_type(&field.type_name, &resolved_variant); + + let value = parse_field_value(&concrete_type, field.name, raw_value, &context)?; + + // Track graph context for downstream validation + if is_graph_type(&concrete_type) { + context.num_vertices = extract_num_vertices(&value); + context.num_edges = extract_num_edges(&value); + } + + json_map.insert(field.name.to_string(), value); + } + + // Run optional per-problem validator + if let Some(validator) = find_validator(canonical) { + validator(&json_map, args)?; + } + + // Factory deserializes JSON → concrete problem type + let json = serde_json::Value::Object(json_map); + let problem = (variant_entry.factory)(json) + .map_err(|e| anyhow!("Failed to construct {canonical}: {e}"))?; + + emit_dyn_problem_output(&problem, canonical, &resolved_variant, out) +} +``` + +### Phase 4: `get_flag_value()` — Reflective Flag Access + +The `CreateArgs` struct has ~120 `Option` fields. We need to look up a field by name at runtime. Two approaches: + +**Option A (recommended): Build a `HashMap<&str, Option<&str>>` from CreateArgs.** + +Add a method to `CreateArgs`: +```rust +impl CreateArgs { + fn flag_map(&self) -> HashMap<&str, Option<&str>> { + let mut m = HashMap::new(); + m.insert("graph", self.graph.as_deref()); + m.insert("weights", self.weights.as_deref()); + m.insert("edge-weights", self.edge_weights.as_deref()); + // ... all string flags + m + } +} +``` + +This is ~120 lines but purely mechanical and can be generated by a macro or build script. It replaces 5,400 lines. + +**Option B: Use serde to serialize CreateArgs to JSON, then look up fields by name.** + +Derive `Serialize` on `CreateArgs`, serialize to `serde_json::Value`, then access fields by name. Zero boilerplate but adds serde dependency to the CLI args struct. + +**Recommendation:** Option A for explicitness. Option B as fallback if the mechanical list becomes a maintenance burden. + +### Phase 5: Help Text Generation + +Replace the 330-line lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`) with schema-driven help: + +```rust +fn print_schema_help(canonical: &str, schema: &ProblemSchemaEntry, variant: &BTreeMap) -> Result<()> { + eprintln!("Usage: pred create {canonical} [FLAGS]\n"); + eprintln!("Fields:"); + for field in &schema.fields { + let flag = field.name.replace('_', "-"); + let concrete = resolve_type(&field.type_name, variant); + let format = type_format_hint_generic(&concrete); + eprintln!(" --{flag:<25} {:<20} {}", concrete, field.description); + if !format.is_empty() { + eprintln!(" {:<27} Format: {format}", ""); + } + } + // Show canonical example from example_db + if let Some(example) = find_model_example(canonical) { + eprintln!("\nExample:\n pred create {canonical} {}", example.cli_string()); + } + Ok(()) +} +``` + +**`example_for()` elimination:** Delegate to existing `canonical_model_example_specs()` from `src/example_db/model_builders.rs` instead of maintaining a parallel 300-line string table. + +### Phase 6: `create_random` Simplification + +The 480-line `create_random` also has a giant match. For most problems, random creation follows a pattern: +1. Create random graph (with `util::create_random_graph()`) +2. Create random weights (if needed) +3. Construct the problem + +This can be partially genericized using the same schema-driven approach, but random creation involves more problem-specific logic (e.g., SteinerTree needs random terminal selection). Keep the match for now but reduce it by extracting shared patterns into helpers for graph-only, graph+vertex-weight, and graph+edge-weight categories. Target: reduce from 480 to ~200 lines. + +### Phase 7: Per-Problem Validators + +~15-20 problems need custom validation beyond type parsing: + +```rust +type ValidatorFn = fn(&serde_json::Map, &CreateArgs) -> Result<()>; + +fn find_validator(canonical: &str) -> Option { + match canonical { + "GeneralizedHex" => Some(|json, _| { + let source = json["source"].as_u64().unwrap(); + let target = json["target"].as_u64().unwrap(); + if source == target { bail!("source and target must be distinct"); } + Ok(()) + }), + "LengthBoundedDisjointPaths" => Some(validate_lbdp), + // ~15 more + _ => None, + } +} +``` + +This is ~200 lines — the genuinely unique validation logic that can't be eliminated. + +### Phase 8: Non-String Flag Handling + +Some `CreateArgs` fields are non-string types (`Option`, `Option`, `Option`, `bool`). These need special handling in `get_flag_value()`: + +- `source: Option` → convert to string for the generic path +- `k: Option` → same +- `bound: Option` → same +- `random: bool`, `seed: Option`, `edge_prob: Option` → only used by `create_random`, not the schema path + +The `flag_map()` can include these by converting to string: `m.insert("source", self.source.map(|v| v.to_string()))`. Slight ugliness but keeps the generic path uniform. + +Alternatively, keep these as special-case lookups outside the generic loop (they affect <10 problems). + +## File Structure After Refactor + +``` +problemreductions-cli/src/commands/create.rs (~3,000 lines → from 11,049) +├── create() — generic schema-driven dispatch (~80 lines) +├── create_from_example() — unchanged (~40 lines) +├── create_random() — simplified (~200 lines, down from 480) +├── CreateContext — tracking struct for cross-field validation (~20 lines) +├── Type parsers — parse_field_value() + ~15 type handlers (~400 lines) +├── Flag access — flag_map() or equivalent (~130 lines) +├── Help generation — schema-driven help (~60 lines) +├── Validators — per-problem validation (~200 lines) +├── Existing helpers — parse_graph, parse_clauses, etc. (~1,500 lines, kept) +└── Graph parsing utilities — parse_edge_list, etc. (~400 lines, kept) +``` + +**Estimated reduction:** 11,049 → ~3,000 lines (~73% reduction). + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| JSON shape mismatch (field order, missing defaults) | Factory uses `serde_json::from_value` which handles field order. Add integration tests comparing old vs new output for all 177 problems. | +| Generic type resolution fails for complex types | Start with a whitelist of known type patterns. Fall back to problem-specific match arm for unrecognized types. | +| Flag rename breaks external scripts | Add `#[arg(alias = "old-name")]` for all renames. | +| Error messages degrade (generic vs problem-specific) | Include problem name and field name in all error messages. Per-problem validators can add context. | +| `create_random` is harder to genericize | Phase 6 is conservative — extract helpers but keep the match. Revisit later. | + +## Testing Strategy + +1. **Regression tests:** For each of the 177 problems, compare `pred create ` output before and after the refactor. Use the existing `example_for()` args as test inputs. +2. **Round-trip tests:** `pred create X --args | pred solve -` must still work for all problems with ILP paths. +3. **Help text tests:** Verify `pred create ` (no args) produces useful help for 10+ diverse problems. +4. **Flag alias tests:** Verify old flag names still work via aliases. +5. **CLI demo:** `make cli-demo` must pass (exercises all commands). + +## Implementation Order + +1. **Write regression test harness** — capture current output for all 177 problems +2. **Rename CLI flags** — add aliases for backward compat +3. **Implement `flag_map()`** — reflective flag access +4. **Implement type parser registry** — `parse_field_value()` with ~15 type handlers +5. **Implement generic `create()`** — schema-driven dispatch +6. **Implement schema-driven help** — replace lookup tables +7. **Add per-problem validators** — ~15-20 problem-specific checks +8. **Simplify `create_random`** — extract shared patterns +9. **Run regression tests** — verify all 177 problems produce identical output +10. **Remove dead code** — old match arms, old lookup tables From 854085e3f5466639c8b2db98a8f676d8bf68949c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 11:56:33 +0800 Subject: [PATCH 02/13] docs: update design spec with Codex review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix problem count (177 → 189) - Expand type parser table from 16 to 52 types in 3 categories - Add serde edge cases section (try_from, from, skip, custom BigUint) - Add Appendix A with complete flag→field mismatch list - Add generic resolution notes for W, W::Sum, Vec> Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-schema-driven-create-design.md | 96 ++++++++++++++++--- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/docs/plans/2026-04-05-schema-driven-create-design.md b/docs/plans/2026-04-05-schema-driven-create-design.md index 8ff9216f..0704aef2 100644 --- a/docs/plans/2026-04-05-schema-driven-create-design.md +++ b/docs/plans/2026-04-05-schema-driven-create-design.md @@ -2,10 +2,11 @@ **Date:** 2026-04-05 **Goal:** Replace the 11K-line `create.rs` with a schema-driven generic dispatch that uses the existing registry `factory` function, reducing the file by ~50%. +**Reviewed:** 2026-04-05 by Codex (see Appendix A for review findings) ## Problem -`problemreductions-cli/src/commands/create.rs` is 11,049 lines. The bulk is a 5,400-line `match canonical { ... }` that manually builds JSON for each of 177 problems, plus 480 lines for `create_random`, plus 330 lines of lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`). +`problemreductions-cli/src/commands/create.rs` is 11,049 lines. The bulk is a 5,400-line `match canonical { ... }` that manually builds JSON for each of ~189 problems, plus 480 lines for `create_random`, plus 330 lines of lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`). The registry already has a `factory: fn(serde_json::Value) -> Result>` per variant that calls `serde_json::from_value()`. The 5,400 lines are manually doing what the factory can do generically. @@ -51,7 +52,20 @@ fn parse_field_value( ) -> Result ``` -**Type dispatch table (~15-20 entries):** +**Type dispatch table:** + +There are 52 unique `type_name` values across all schema registrations. They fall into three categories: + +**Category 1: Generic types (resolve via variant map first)** +These appear as literal type_name strings and must be resolved before dispatch: +- `G` → resolve via `variant["graph"]` → concrete graph type +- `W` → resolve via `variant["weight"]` → `One`, `i32`, or `f64` +- `W::Sum` → resolve `W` first, then map: `One` → `usize`, `i32` → `i32`, `f64` → `f64` +- `Vec`, `Vec>`, `Vec>`, `Vec<(usize, usize, W)>` → substitute generic param, then dispatch on resolved type + +**Note:** Whitespace in type_name strings is inconsistent (e.g., `Vec<(usize,usize)>` vs `Vec<(usize, usize)>`). Normalize by stripping spaces before matching. + +**Category 2: Concrete types with direct CLI parsing (~20 entries)** | Concrete type pattern | Parse strategy | Existing helper | |---|---|---| @@ -61,21 +75,40 @@ fn parse_field_value( | `UnitDiskGraph` | positions + radius | `parse_unit_disk_graph()` | | `DirectedGraph` | arc list → `{num_vertices, arcs}` | `parse_directed_graph()` | | `MixedGraph` | graph + arcs | `parse_mixed_graph()` | -| `Vec` / `Vec` / `Vec` | comma-separated numbers | `parse_numeric_list::()` | +| `Vec` / `Vec` / `Vec` / `Vec` / `Vec` | comma-separated numbers | `parse_numeric_list::()` | | `Vec` | auto-fill unit weights (length from context) | fill with `1`s | -| `Vec>` | semicolon-separated groups | `parse_nested_usize_list()` | +| `Vec` | comma-separated 0/1 or true/false | `parse_bool_list()` | +| `Vec>` / `Vec>` / `Vec>` / `Vec>` / `Vec>` | semicolon-separated rows | `parse_nested_list::()` | +| `Vec>>` | pipe-separated matrices | `parse_3d_list()` | | `Vec<[usize; 3]>` | semicolon-separated triples | `parse_triple_list()` | | `Vec` | semicolon-separated signed literals | `parse_clauses()` | | `Vec<(usize, usize)>` | pair list (comma or `>` separated) | `parse_pair_list()` | +| `Vec<(usize, f64)>` | pair list | `parse_typed_pair_list()` | +| `Vec<(usize, usize, usize)>` / `Vec<(usize, usize, usize, usize)>` | tuple list | `parse_tuple_list()` | +| `Vec<(usize, Vec)>` / `Vec<(Vec, Vec)>` / `Vec<(Vec, usize)>` | nested pair list | `parse_complex_pair_list()` | | `Vec>` | job-task format | `parse_job_shop_jobs()` | -| `usize` / `u64` / `i32` / `f64` | single number parse | `str::parse::()` | +| `Vec` | semicolon-separated strings | `split(';')` | +| `Vec` / `BigUint` | comma-separated decimal strings | custom decimal parse (uses biguint_serde) | +| `Vec>` | comma-separated with "?" for None | `parse_optional_bool_list()` | +| `usize` / `u64` / `i32` / `i64` / `f64` | single number parse | `str::parse::()` | | `One` (scalar unit weight) | skip field / default to `null` | (handled by serde default) | | `bool` | "true"/"false" parse | `str::parse::()` | -**Generic type resolution:** `FieldInfo.type_name` uses generic names ("G", "Vec"). Resolve using the variant map: -- `"G"` → look up `variant["graph"]` → `"SimpleGraph"` -- `"Vec"` → look up `variant["weight"]` → substitute `W` → `"Vec"` -- Concrete types like `"Vec"` or `"usize"` → use directly +**Category 3: Complex domain types (passthrough as JSON)** +These types have custom serde and are best handled by passing the raw CLI string through the existing problem-specific parser, or by skipping CLI creation entirely (like ILP/CircuitSAT): +- `Circuit` → CircuitSAT (already excluded from CLI create) +- `IntExpr` → IntegerExpressionMembership (JSON expression tree via `--expression`) +- `ObjectiveSense` → ILP (already excluded from CLI create) +- `Vec` → ILP (already excluded from CLI create) +- `Vec` → QBF (comma-separated E/A via `--quantifiers`) +- `Vec` → ConjunctiveBooleanQuery (custom format via `--relations`) +- `Vec` → ConsistencyOfDatabaseFrequencyTables (custom format via `--frequency-tables`) +- `Vec` → ConsistencyOfDatabaseFrequencyTables (custom format via `--known-values`) +- `Vec` → ClosestVectorProblem (custom format via `--bounds`) +- `Vec<(usize, Vec)>` → ConjunctiveBooleanQuery (custom format via `--conjuncts-spec`) +- `Vec<(usize, Vec)>` → ConjunctiveQueryFoldability (tagged enum JSON) + +For Category 3, the parse function dispatches on `(problem_name, field_name)` rather than type alone, since these formats are problem-specific. This is a small lookup table (~10 entries) separate from the generic type dispatch. ### Phase 3: Generic `create()` Function @@ -252,6 +285,25 @@ problemreductions-cli/src/commands/create.rs (~3,000 lines → from 11,049) **Estimated reduction:** 11,049 → ~3,000 lines (~73% reduction). +## Serde Edge Cases + +Several problems use serde customization that affects the factory JSON path: + +**`#[serde(try_from)]` — validation wrappers (6 problems):** +`NAESatisfiability`, `StackerCrane`, `SetSplitting`, `RootedTreeStorageAssignment`, `EnsembleComputation`, `ConsecutiveBlockMinimization`. These keep the same JSON keys but reject invalid input after parsing. The factory will return an error with a validation message — this is correct behavior and needs no special handling. + +**`#[serde(from)]` + skip/default — cache fields:** +`BalancedCompleteBipartiteSubgraph` uses `#[serde(from)]` with internal cache fields. `KColoring` has `#[serde(default)]` + `#[serde(skip)]` on internal fields. These are transparent to the JSON input — the schema-exposed fields are the only ones needed. + +**`#[serde(skip)]` — phantom/const fields:** +`KSatisfiability` and `ILP` skip phantom type fields. These don't appear in JSON and need no CLI parsing. + +**Custom serde for `BigUint`:** +`SubsetSum` and `SubsetProduct` use custom decimal-string serde for `BigUint` fields (`biguint_serde` module). The CLI parser should produce decimal strings matching this format. + +**Tagged enum types:** +`Term` in `ConjunctiveQueryFoldability` serializes as a tagged JSON object, not a plain scalar. The CLI flag passes raw JSON for this type. + ## Risks and Mitigations | Risk | Mitigation | @@ -272,13 +324,31 @@ problemreductions-cli/src/commands/create.rs (~3,000 lines → from 11,049) ## Implementation Order -1. **Write regression test harness** — capture current output for all 177 problems -2. **Rename CLI flags** — add aliases for backward compat +1. **Write regression test harness** — capture current output for all 189 problems +2. **Rename CLI flags** — add aliases for backward compat (see Phase 1 + Appendix A for complete list) 3. **Implement `flag_map()`** — reflective flag access -4. **Implement type parser registry** — `parse_field_value()` with ~15 type handlers +4. **Implement type parser registry** — `parse_field_value()` with all 3 categories of type handlers 5. **Implement generic `create()`** — schema-driven dispatch 6. **Implement schema-driven help** — replace lookup tables 7. **Add per-problem validators** — ~15-20 problem-specific checks 8. **Simplify `create_random`** — extract shared patterns -9. **Run regression tests** — verify all 177 problems produce identical output +9. **Run regression tests** — verify all 189 problems produce identical output 10. **Remove dead code** — old match arms, old lookup tables + +## Appendix A: Codex Review Findings (2026-04-05) + +### Additional Flag→Field Mismatches (not in Phase 1 table) + +The following field names have no matching CLI flag via `snake_case → kebab-case`. Each needs either a new flag or a rename: + +**Algebraic:** `ILP` (`constraints`, `objective`, `sense` — already excluded from CLI), `QuadraticCongruences` (`a`, `b`, `c`), `QuadraticDiophantineEquations` (`a`, `b`, `c`) + +**Graph:** `AcyclicPartition` (`vertex_weights`), `BicliqueCover` (`left_size`, `right_size`, `edges`), `BoundedComponentSpanningForest` (`max_components`), `DegreeConstrainedSpanningTree` (`max_degree`), `LengthBoundedDisjointPaths` (`max_paths`), `MinMaxMulticenter` (`vertex_weights`), `MinimumCapacitatedSpanningTree` (`root`), `MinimumEdgeCostFlow` (`prices`, `required_flow`), `MinimumSumMulticenter` (`vertex_weights`), `PartitionIntoCliques` (`num_cliques`), `PartitionIntoForests` (`num_forests`), `PartitionIntoPerfectMatchings` (`num_matchings`) + +**Misc:** `Betweenness` (`num_elements`, `triples`), `BoyceCoddNormalFormViolation` (`functional_deps`, `target_subset`), `CapacityAssignment` (`cost`, `delay`), `Clustering` (`distances`, `num_clusters`), `ConjunctiveBooleanQuery` (`conjuncts`), `ConjunctiveQueryFoldability` (`num_distinguished`, `num_undistinguished`, `relation_arities`, `query1_conjuncts`, `query2_conjuncts`), `CyclicOrdering` (`num_elements`, `triples`), `DynamicStorageAllocation` (`items`, `memory_size`), `FeasibleRegisterAssignment` (`num_registers`), `MinimumAxiomSet` (`num_sentences`), `MinimumCodeGenerationOneRegister` (`edges`, `num_leaves`), `MinimumRegisterSufficiencyForLoops` (`variables`), `NonLivenessFreePetriNet` (`num_places`, `num_transitions`, `place_to_transition`, `transition_to_place`), `Numerical3DimensionalMatching` (`sizes_w`, `sizes_x`, `sizes_y`), `NumericalMatchingWithTargetSums` (`sizes_x`, `sizes_y`, `targets`), `OpenShopScheduling` (`num_machines`, `processing_times`), `StackerCrane` (`edges`), `StaffScheduling` (`shifts_per_schedule`) + +**Set:** `SetBasis` (`collection`), `ThreeDimensionalMatching` (`triples`), `ThreeMatroidIntersection` (`ground_set_size`) + +### Implementation Note + +Many of these "mismatches" are fields that already have CLI flags under a *different* name (e.g., `vertex_weights` → `--weights`, `max_components` → `--k`). The Phase 1 rename aligns them. Some fields use generic flags like `--bound` that map to multiple field names — Phase 1 splits these into specific flags matching each field name. Fields for ILP and CircuitSAT are excluded since those problems already reject CLI creation. From 5538b3c2e9e56fd4915863776c6da63df6f2784a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 12:19:01 +0800 Subject: [PATCH 03/13] Phase 1: align create CLI flags with schema names --- problemreductions-cli/src/cli.rs | 166 +++++++--- problemreductions-cli/src/commands/create.rs | 310 +++++++++++-------- problemreductions-cli/tests/cli_tests.rs | 100 +++--- 3 files changed, 345 insertions(+), 231 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f1b54917..a6e994ca 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -240,7 +240,7 @@ Flags by problem type: HamiltonianCircuit, HC --graph MaximumLeafSpanningTree --graph LongestCircuit --graph, --edge-weights - BoundedComponentSpanningForest --graph, --weights, --k, --bound + BoundedComponentSpanningForest --graph, --weights, --k, --max-weight UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement IntegralFlowBundles --arcs, --bundles, --bundle-capacities, --source, --sink, --requirement [--num-vertices] UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 @@ -248,7 +248,7 @@ Flags by problem type: IntegralFlowHomologousArcs --arcs, --capacities, --source, --sink, --requirement, --homologous-pairs IsomorphicSpanningTree --graph, --tree KthBestSpanningTree --graph, --edge-weights, --k, --bound - LengthBoundedDisjointPaths --graph, --source, --sink, --bound + LengthBoundedDisjointPaths --graph, --source, --sink, --max-length PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement Factoring --target, --m, --n BinPacking --sizes, --capacity @@ -270,22 +270,22 @@ Flags by problem type: SumOfSquaresPartition --sizes, --num-groups ExpectedRetrievalCost --probabilities, --num-sectors PaintShop --sequence - MaximumSetPacking --sets [--weights] - MinimumHittingSet --universe, --sets - MinimumSetCovering --universe, --sets [--weights] - EnsembleComputation --universe, --sets, --budget - ComparativeContainment --universe, --r-sets, --s-sets [--r-weights] [--s-weights] - X3C (ExactCoverBy3Sets) --universe, --sets (3 elements each) - 3DM (ThreeDimensionalMatching) --universe, --sets (triples w,x,y) - ThreeMatroidIntersection --universe, --partitions, --bound - SetBasis --universe, --sets, --k + MaximumSetPacking --subsets [--weights] + MinimumHittingSet --universe-size, --subsets + MinimumSetCovering --universe-size, --subsets [--weights] + EnsembleComputation --universe-size, --subsets, --budget + ComparativeContainment --universe-size, --r-sets, --s-sets [--r-weights] [--s-weights] + X3C (ExactCoverBy3Sets) --universe-size, --subsets (3 elements each) + 3DM (ThreeDimensionalMatching) --universe-size, --subsets (triples w,x,y) + ThreeMatroidIntersection --universe-size, --partitions, --bound + SetBasis --universe-size, --subsets, --k MinimumCardinalityKey --num-attributes, --dependencies - PrimeAttributeName --universe, --deps, --query - RootedTreeStorageAssignment --universe, --sets, --bound - TwoDimensionalConsecutiveSets --alphabet-size, --sets + PrimeAttributeName --universe, --dependencies, --query-attribute + RootedTreeStorageAssignment --universe-size, --subsets, --bound + TwoDimensionalConsecutiveSets --alphabet-size, --subsets BicliqueCover --left, --right, --biedges, --k BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k - BiconnectivityAugmentation --graph, --potential-edges, --budget [--num-vertices] + BiconnectivityAugmentation --graph, --potential-weights, --budget [--num-vertices] PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices] BMF --matrix (0/1), --rank ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k @@ -298,7 +298,7 @@ Flags by problem type: FeasibleBasisExtension --matrix (JSON 2D i64), --rhs, --required-columns SteinerTree --graph, --edge-weights, --terminals MultipleCopyFileAllocation --graph, --usage, --storage - AcyclicPartition --arcs [--weights] [--arc-costs] --weight-bound --cost-bound [--num-vertices] + AcyclicPartition --arcs [--weights] [--arc-weights] --weight-bound --cost-bound [--num-vertices] CVP --basis, --target-vec [--bounds] MultiprocessorScheduling --lengths, --num-processors, --deadline SchedulingToMinimizeWeightedCompletionTime --lengths, --weights, --num-processors @@ -306,10 +306,10 @@ Flags by problem type: OptimalLinearArrangement --graph RootedTreeArrangement --graph, --bound MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k - MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs [--num-vertices] + MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-weights [--num-vertices] RuralPostman (RPP) --graph, --edge-weights, --required-edges - StackerCrane --arcs, --graph, --arc-costs, --edge-lengths [--num-vertices] - MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] + StackerCrane --arcs, --graph, --arc-lengths, --edge-lengths [--num-vertices] + MultipleChoiceBranching --arcs [--weights] --partition --threshold [--num-vertices] AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] ConsistencyOfDatabaseFrequencyTables --num-objects, --attribute-domains, --frequency-tables [--known-values] SubgraphIsomorphism --graph (host), --pattern (pattern) @@ -325,18 +325,18 @@ Flags by problem type: PartiallyOrderedKnapsack --sizes, --values, --capacity, --precedences QAP --matrix (cost), --distance-matrix StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] - JobShopScheduling --job-tasks [--num-processors] + JobShopScheduling --jobs [--num-processors] FlowShopScheduling --task-lengths, --deadline [--num-processors] StaffScheduling --schedules, --requirements, --num-workers, --k TimetableDesign --num-periods, --num-craftsmen, --num-tasks, --craftsman-avail, --task-avail, --requirements - MinimumTardinessSequencing --n, --deadlines [--precedence-pairs] + MinimumTardinessSequencing --num-tasks, --deadlines [--precedences] RectilinearPictureCompression --matrix (0/1), --k - SchedulingWithIndividualDeadlines --n, --num-processors/--m, --deadlines [--precedence-pairs] - SequencingToMinimizeMaximumCumulativeCost --costs [--precedence-pairs] - SequencingToMinimizeTardyTaskWeight --sizes, --weights, --deadlines - SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedence-pairs] - SequencingToMinimizeWeightedTardiness --sizes, --weights, --deadlines, --bound - SequencingWithDeadlinesAndSetUpTimes --sizes, --deadlines, --compilers, --setup-times + SchedulingWithIndividualDeadlines --num-tasks, --num-processors/--m, --deadlines [--precedences] + SequencingToMinimizeMaximumCumulativeCost --costs [--precedences] + SequencingToMinimizeTardyTaskWeight --lengths, --weights, --deadlines + SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedences] + SequencingToMinimizeWeightedTardiness --lengths, --weights, --deadlines, --bound + SequencingWithDeadlinesAndSetUpTimes --lengths, --deadlines, --compilers, --setup-times MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] MinimumInternalMacroDataCompression --string, --pointer-cost [--alphabet-size] SCS --strings [--alphabet-size] @@ -388,16 +388,16 @@ Examples: pred create SchedulingToMinimizeWeightedCompletionTime --lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2 pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3 pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" - pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 + pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5 pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6 pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\" - pred create X3C --universe 9 --sets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" - pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3 + pred create X3C --universe-size 9 --subsets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" + pred create SetBasis --universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3 pred create MinimumCardinalityKey --num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\" - pred create PrimeAttributeName --universe 6 --deps \"0,1>2,3,4,5;2,3>0,1,4,5\" --query 3 - pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets \"0,1,2;3,4,5;1,3;2,4;0,5\"")] + pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3 + pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"")] pub struct CreateArgs { /// Problem type (e.g., MIS, QUBO, SAT). Omit when using --example. #[arg(value_parser = crate::problem_name::ProblemNameParser)] @@ -556,8 +556,8 @@ pub struct CreateArgs { /// Car paint sequence for PaintShop (comma-separated, each label appears exactly twice, e.g., "a,b,a,c,c,b") #[arg(long)] pub sequence: Option, - /// Sets for set-system problems such as SetPacking, MinimumHittingSet, and SetCovering (semicolon-separated, e.g., "0,1;1,2;0,2") - #[arg(long)] + /// Subsets for set-system problems such as SetPacking, MinimumHittingSet, and SetCovering (semicolon-separated, e.g., "0,1;1,2;0,2") + #[arg(long = "subsets", alias = "sets")] pub sets: Option, /// R-family sets for ComparativeContainment (semicolon-separated, e.g., "0,1;1,2") #[arg(long)] @@ -581,7 +581,7 @@ pub struct CreateArgs { #[arg(long)] pub bundles: Option, /// Universe size for set-system problems such as MinimumHittingSet, MinimumSetCovering, and ComparativeContainment - #[arg(long)] + #[arg(long = "universe-size", alias = "universe")] pub universe: Option, /// Bipartite graph edges for BicliqueCover / BalancedCompleteBipartiteSubgraph (e.g., "0-0,0-1,1-2" for left-right pairs) #[arg(long)] @@ -623,7 +623,14 @@ pub struct CreateArgs { #[arg(long)] pub required_edges: Option, /// Bound parameter (upper or length bound for BoundedComponentSpanningForest, GroupingBySwapping, LengthBoundedDisjointPaths, MultipleChoiceBranching, RootedTreeArrangement, or StringToStringCorrection) - #[arg(long, allow_hyphen_values = true)] + #[arg( + long, + alias = "max-length", + alias = "max-weight", + alias = "bound-k", + alias = "threshold", + allow_hyphen_values = true + )] pub bound: Option, /// Upper bound on expected retrieval latency for ExpectedRetrievalCost #[arg(long)] @@ -655,8 +662,8 @@ pub struct CreateArgs { /// Task costs for SequencingToMinimizeMaximumCumulativeCost (comma-separated, e.g., "2,-1,3,-2,1,-3") #[arg(long, allow_hyphen_values = true)] pub costs: Option, - /// Arc costs for directed graph problems with per-arc costs (comma-separated, e.g., "1,1,2,3") - #[arg(long)] + /// Arc weights/lengths for directed graph problems with per-arc costs (comma-separated, e.g., "1,1,2,3") + #[arg(long = "arc-weights", alias = "arc-costs", alias = "arc-lengths")] pub arc_costs: Option, /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] @@ -689,7 +696,7 @@ pub struct CreateArgs { #[arg(long)] pub distance_matrix: Option, /// Weighted potential augmentation edges (e.g., 0-2:3,1-3:5) - #[arg(long)] + #[arg(long = "potential-weights", alias = "potential-edges")] pub potential_edges: Option, /// Total budget for selected potential edges #[arg(long)] @@ -722,7 +729,7 @@ pub struct CreateArgs { #[arg(long)] pub task_lengths: Option, /// Job tasks for JobShopScheduling (semicolon-separated jobs, comma-separated processor:length tasks, e.g., "0:3,1:4;1:2,0:3,1:2") - #[arg(long)] + #[arg(long = "jobs", alias = "job-tasks")] pub job_tasks: Option, /// Deadline for FlowShopScheduling, MultiprocessorScheduling, or ResourceConstrainedScheduling #[arg(long)] @@ -795,7 +802,7 @@ pub struct CreateArgs { #[arg(long)] pub deps: Option, /// Query attribute index for PrimeAttributeName - #[arg(long)] + #[arg(long = "query-attribute", alias = "query")] pub query: Option, /// Right-hand side vector for FeasibleBasisExtension (comma-separated, e.g., "7,5,3") #[arg(long)] @@ -1064,7 +1071,7 @@ mod tests { "create help should describe --num-processors for both scheduling models" ); assert!(help.contains( - "SchedulingWithIndividualDeadlines --n, --num-processors/--m, --deadlines [--precedence-pairs]" + "SchedulingWithIndividualDeadlines --num-tasks, --num-processors/--m, --deadlines [--precedences]" )); } @@ -1076,7 +1083,7 @@ mod tests { "BiconnectivityAugmentation", "--graph", "0-1,1-2", - "--potential-edges", + "--potential-weights", "0-2:3,1-3:5", "--budget", "7", @@ -1092,6 +1099,27 @@ mod tests { assert_eq!(args.budget.as_deref(), Some("7")); } + #[test] + fn test_create_parses_biconnectivity_augmentation_legacy_flag_alias() { + let cli = Cli::parse_from([ + "pred", + "create", + "BiconnectivityAugmentation", + "--graph", + "0-1,1-2", + "--potential-edges", + "0-2:3,1-3:5", + "--budget", + "7", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.potential_edges.as_deref(), Some("0-2:3,1-3:5")); + } + #[test] fn test_create_help_mentions_biconnectivity_augmentation_flags() { let cmd = Cli::command(); @@ -1102,10 +1130,58 @@ mod tests { .to_string(); assert!(help.contains("BiconnectivityAugmentation")); - assert!(help.contains("--potential-edges")); + assert!(help.contains("--potential-weights")); assert!(help.contains("--budget")); } + #[test] + fn test_create_parses_job_shop_scheduling_jobs_flag() { + let cli = Cli::parse_from([ + "pred", + "create", + "JobShopScheduling", + "--jobs", + "0:3,1:4;1:2,0:3,1:2", + "--num-processors", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.problem.as_deref(), Some("JobShopScheduling")); + assert_eq!(args.job_tasks.as_deref(), Some("0:3,1:4;1:2,0:3,1:2")); + assert_eq!(args.num_processors, Some(2)); + } + + #[test] + fn test_create_parses_prime_attribute_name_canonical_flags() { + let cli = Cli::parse_from([ + "pred", + "create", + "PrimeAttributeName", + "--universe", + "6", + "--dependencies", + "0,1>2,3,4,5;2,3>0,1,4,5", + "--query-attribute", + "3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.problem.as_deref(), Some("PrimeAttributeName")); + assert_eq!(args.universe, Some(6)); + assert_eq!( + args.dependencies.as_deref(), + Some("0,1>2,3,4,5;2,3>0,1,4,5") + ); + assert_eq!(args.query, Some(3)); + } + #[test] fn test_create_parses_partial_feedback_edge_set_flags() { let cli = Cli::parse_from([ @@ -1156,7 +1232,7 @@ mod tests { assert!(help.contains("StackerCrane")); assert!(help.contains("--arcs")); assert!(help.contains("--graph")); - assert!(help.contains("--arc-costs")); + assert!(help.contains("--arc-lengths")); assert!(help.contains("--edge-lengths")); assert!(help.contains("--bound")); assert!(help.contains("--num-vertices")); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 5cbb0e94..71551cc3 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -484,19 +484,19 @@ fn parse_precedence_pairs(raw: Option<&str>) -> Result> { let pair = pair.trim(); let (pred, succ) = pair.split_once('>').ok_or_else(|| { anyhow::anyhow!( - "Invalid --precedence-pairs value '{}': expected 'u>v'", + "Invalid --precedences value '{}': expected 'u>v'", pair ) })?; let pred = pred.trim().parse::().map_err(|_| { anyhow::anyhow!( - "Invalid --precedence-pairs value '{}': expected 'u>v' with nonnegative integer indices", + "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", pair ) })?; let succ = succ.trim().parse::().map_err(|_| { anyhow::anyhow!( - "Invalid --precedence-pairs value '{}': expected 'u>v' with nonnegative integer indices", + "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", pair ) })?; @@ -519,7 +519,7 @@ fn parse_job_shop_jobs(raw: &str) -> Result>> { let job_str = job_str.trim(); anyhow::ensure!( !job_str.is_empty(), - "Invalid --job-tasks value: empty job at position {}", + "Invalid --jobs value: empty job at position {}", job_index ); @@ -529,19 +529,19 @@ fn parse_job_shop_jobs(raw: &str) -> Result>> { let task_str = task_str.trim(); let (processor, length) = task_str.split_once(':').ok_or_else(|| { anyhow::anyhow!( - "Invalid --job-tasks operation '{}': expected 'processor:length'", + "Invalid --jobs operation '{}': expected 'processor:length'", task_str ) })?; let processor = processor.trim().parse::().map_err(|_| { anyhow::anyhow!( - "Invalid --job-tasks operation '{}': processor must be a nonnegative integer", + "Invalid --jobs operation '{}': processor must be a nonnegative integer", task_str ) })?; let length = length.trim().parse::().map_err(|_| { anyhow::anyhow!( - "Invalid --job-tasks operation '{}': length must be a nonnegative integer", + "Invalid --jobs operation '{}': length must be a nonnegative integer", task_str ) })?; @@ -653,7 +653,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --source 0 --sink 3 --size-bound 3" } "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" + "--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 --max-weight 6" } "HamiltonianPath" => "--graph 0-1,1-2,2-3", "HamiltonianPathBetweenTwoVertices" => { @@ -676,7 +676,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\"" } "LengthBoundedDisjointPaths" => { - "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --bound 4" + "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 4" } "PathConstrainedNetworkFlow" => { "--arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3" @@ -697,7 +697,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "SteinerTreeInGraphs" => "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --terminals 0,3", "BiconnectivityAugmentation" => { - "--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" + "--graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5" } "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" @@ -724,8 +724,10 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", "MaximumLeafSpanningTree" => "--graph 0-1,0-2,0-3,1-4,2-4,2-5,3-5,4-5,1-3", - "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\"", - "RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1", + "EnsembleComputation" => "--universe-size 4 --subsets \"0,1,2;0,1,3\"", + "RootedTreeStorageAssignment" => { + "--universe-size 5 --subsets \"0,2;1,3;0,4;2,4\" --bound 1" + } "MinMaxMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" } @@ -755,13 +757,13 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", "PreemptiveScheduling" => { - "--sizes 2,1,3,2,1 --num-processors 2 --precedence-pairs \"0>2,1>3\"" + "--lengths 2,1,3,2,1 --num-processors 2 --precedences \"0>2,1>3\"" } "SchedulingToMinimizeWeightedCompletionTime" => { "--lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2" } "JobShopScheduling" => { - "--job-tasks \"0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1\" --num-processors 2" + "--jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1\" --num-processors 2" } "MinimumMultiwayCut" => "--graph 0-1,1-2,2-3 --terminals 0,2 --edge-weights 1,1,1", "ExpectedRetrievalCost" => EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS, @@ -777,7 +779,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS } "AcyclicPartition" => { - "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" + "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-weights 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" } "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3", "RootedTreeArrangement" => "--graph 0-1,0-2,1-2,2-3,3-4 --bound 7", @@ -815,16 +817,16 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" } "MixedChinesePostman" => { - "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4" + "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4" } "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2" } "StackerCrane" => { - "--arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-costs 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6" + "--arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6" } "MultipleChoiceBranching" => { - "--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" + "--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\" --threshold 10" } "AdditionalKey" => "--num-attributes 6 --dependencies \"0,1:2,3;2,3:4,5;4,5:0,1\" --relation-attrs 0,1,2,3,4,5 --known-keys \"0,1;2,3;4,5\"", "ConsistencyOfDatabaseFrequencyTables" => { @@ -835,7 +837,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --bound 2" } "SequencingToMinimizeWeightedTardiness" => { - "--sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + "--lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" } "IntegerKnapsack" => "--sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15", "SubsetProduct" => "--sizes 2,3,5,7,6,10 --target 210", @@ -867,10 +869,10 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3", "ComparativeContainment" => { - "--universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6" + "--universe-size 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6" } - "SetBasis" => "--universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3", - "SetSplitting" => "--universe 6 --sets \"0,1,2;2,3,4;0,4,5;1,3,5\"", + "SetBasis" => "--universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3", + "SetSplitting" => "--universe-size 6 --subsets \"0,1,2;2,3,4;0,4,5;1,3,5\"", "LongestCommonSubsequence" => { "--strings \"010110;100101;001011\" --alphabet-size 2" } @@ -882,21 +884,17 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\"" } "PrimeAttributeName" => { - "--universe 6 --deps \"0,1>2,3,4,5;2,3>0,1,4,5\" --query 3" + "--universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" } "TwoDimensionalConsecutiveSets" => { - "--alphabet-size 6 --sets \"0,1,2;3,4,5;1,3;2,4;0,5\"" + "--alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"" } "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\"", - "ConsecutiveBlockMinimization" => { - "--matrix '[[true,false,true],[false,true,true]]' --bound 2" - } + "ConsecutiveBlockMinimization" => "--matrix '[[true,false,true],[false,true,true]]' --bound-k 2", "ConsecutiveOnesMatrixAugmentation" => { "--matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" } - "SparseMatrixCompression" => { - "--matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound 2" - } + "SparseMatrixCompression" => "--matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2", "MaximumLikelihoodRanking" => "--matrix \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"", "MinimumMatrixCover" => "--matrix \"0,3,1,0;3,0,0,2;1,0,0,4;0,2,4,0\"", "MinimumMatrixDomination" => "--matrix \"0,1,0;1,0,1;0,1,0\"", @@ -955,23 +953,23 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { // Problem-specific overrides first match (canonical, field_name) { ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), - ("BoundedComponentSpanningForest", "max_weight") => return "bound".to_string(), + ("BoundedComponentSpanningForest", "max_weight") => return "max-weight".to_string(), ("FlowShopScheduling", "num_processors") | ("JobShopScheduling", "num_processors") | ("OpenShopScheduling", "num_machines") | ("SchedulingWithIndividualDeadlines", "num_processors") => { return "num-processors/--m".to_string(); } - ("JobShopScheduling", "jobs") => return "job-tasks".to_string(), - ("LengthBoundedDisjointPaths", "max_length") => return "bound".to_string(), + ("JobShopScheduling", "jobs") => return "jobs".to_string(), + ("LengthBoundedDisjointPaths", "max_length") => return "max-length".to_string(), ("RectilinearPictureCompression", "bound") => return "bound".to_string(), ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), - ("PrimeAttributeName", "dependencies") => return "deps".to_string(), - ("PrimeAttributeName", "query_attribute") => return "query".to_string(), - ("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(), + ("PrimeAttributeName", "dependencies") => return "dependencies".to_string(), + ("PrimeAttributeName", "query_attribute") => return "query-attribute".to_string(), + ("MixedChinesePostman", "arc_weights") => return "arc-weights".to_string(), ("ConsecutiveOnesMatrixAugmentation", "bound") => return "bound".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), - ("SparseMatrixCompression", "bound_k") => return "bound".to_string(), + ("SparseMatrixCompression", "bound_k") => return "bound-k".to_string(), ("MinimumCodeGenerationParallelAssignments", "num_variables") => { return "num-variables".to_string(); } @@ -979,7 +977,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { return "assignments".to_string(); } ("StackerCrane", "edges") => return "graph".to_string(), - ("StackerCrane", "arc_lengths") => return "arc-costs".to_string(), + ("StackerCrane", "arc_lengths") => return "arc-lengths".to_string(), ("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), @@ -991,18 +989,18 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { } // General field-name overrides (previously in cli_flag_name) match field_name { - "universe_size" => "universe".to_string(), - "collection" | "subsets" => "sets".to_string(), + "universe_size" => "universe-size".to_string(), + "collection" | "subsets" => "subsets".to_string(), "left_size" => "left".to_string(), "right_size" => "right".to_string(), "edges" => "biedges".to_string(), "vertex_weights" => "weights".to_string(), - "potential_weights" => "potential-edges".to_string(), + "potential_weights" => "potential-weights".to_string(), "edge_lengths" => "edge-weights".to_string(), - "num_tasks" => "n".to_string(), - "precedences" => "precedence-pairs".to_string(), - "threshold" => "bound".to_string(), - "lengths" => "sizes".to_string(), + "num_tasks" => "num-tasks".to_string(), + "precedences" => "precedences".to_string(), + "threshold" => "threshold".to_string(), + "lengths" => "lengths".to_string(), _ => field_name.replace('_', "-"), } } @@ -1257,7 +1255,7 @@ fn problem_help_flag_name( return "graph".to_string(); } if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { - return "bound".to_string(); + return "max-length".to_string(); } if canonical == "GeneralizedHex" && field_name == "target" { return "sink".to_string(); @@ -1289,7 +1287,7 @@ fn validate_length_bounded_disjoint_paths_args( ) -> Result { let max_length = usize::try_from(bound).map_err(|_| { lbdp_validation_error( - "--bound must be a nonnegative integer for LengthBoundedDisjointPaths", + "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths", usage, ) })?; @@ -1306,7 +1304,7 @@ fn validate_length_bounded_disjoint_paths_args( )); } if max_length == 0 { - return Err(lbdp_validation_error("--bound must be positive", usage)); + return Err(lbdp_validation_error("--max-length must be positive", usage)); } Ok(max_length) } @@ -1653,7 +1651,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "BiconnectivityAugmentation" => { let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( - "{e}\n\nUsage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5" + "{e}\n\nUsage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5" ) })?; let potential_edges = parse_potential_edges(args)?; @@ -1696,7 +1694,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // Bounded Component Spanning Forest "BoundedComponentSpanningForest" => { - 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"; + 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 --max-weight 6"; let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; args.weights.as_deref().ok_or_else(|| { anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") @@ -1712,14 +1710,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); } let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --bound\n\n{usage}") + anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") })?; if bound_raw <= 0 { - bail!("BoundedComponentSpanningForest requires positive --bound\n\n{usage}"); + bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); } let max_weight = i32::try_from(bound_raw).map_err(|_| { anyhow::anyhow!( - "BoundedComponentSpanningForest requires --bound within i32 range\n\n{usage}" + "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" ) })?; ( @@ -2080,7 +2078,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // LengthBoundedDisjointPaths (graph + source + sink + bound) "LengthBoundedDisjointPaths" => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --bound 3"; + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; let source = args.source.ok_or_else(|| { anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") @@ -2089,7 +2087,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") })?; let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --bound\n\n{usage}") + anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") })?; let max_length = validate_length_bounded_disjoint_paths_args( graph.num_vertices(), @@ -2281,7 +2279,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // StackerCrane "StackerCrane" => { - let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-costs 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; + let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; let arcs_str = args .arcs .as_deref() @@ -2318,7 +2316,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // MultipleChoiceBranching "MultipleChoiceBranching" => { - let usage = "Usage: 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"; + let usage = "Usage: 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\" --threshold 10"; let arcs_str = args.arcs.as_deref().ok_or_else(|| { anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") })?; @@ -3793,14 +3791,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // ConsecutiveBlockMinimization "ConsecutiveBlockMinimization" => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound 2"; + let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; let matrix_str = args.matrix.as_deref().ok_or_else(|| { anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound\n\n{usage}" + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" ) })?; let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound\n\n{usage}") + anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") })?; let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { anyhow::anyhow!( @@ -3863,9 +3861,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // SparseMatrixCompression "SparseMatrixCompression" => { let matrix = parse_bool_matrix(args)?; - let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound 2"; + let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("SparseMatrixCompression requires --matrix and --bound\n\n{usage}") + anyhow::anyhow!("SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}") })?; let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; if bound == 0 { @@ -4443,10 +4441,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // PreemptiveScheduling "PreemptiveScheduling" => { - let usage = "Usage: pred create PreemptiveScheduling --sizes 2,1,3,2,1 --num-processors 2 [--precedence-pairs \"0>2,1>3\"]"; - let sizes_str = args.sizes.as_deref().ok_or_else(|| { + let usage = "Usage: pred create PreemptiveScheduling --lengths 2,1,3,2,1 --num-processors 2 [--precedences \"0>2,1>3\"]"; + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { anyhow::anyhow!( - "PreemptiveScheduling requires --sizes and --num-processors\n\n{usage}" + "PreemptiveScheduling requires --lengths and --num-processors\n\n{usage}" ) })?; let num_processors = args.num_processors.ok_or_else(|| { @@ -4456,13 +4454,14 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { num_processors > 0, "PreemptiveScheduling requires --num-processors > 0\n\n{usage}" ); - let lengths: Vec = util::parse_comma_list(sizes_str)?; + let lengths: Vec = util::parse_comma_list(lengths_str)?; anyhow::ensure!( lengths.iter().all(|&l| l > 0), "PreemptiveScheduling: all task lengths must be positive\n\n{usage}" ); let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = match args.precedence_pairs.as_deref() { + let precedences: Vec<(usize, usize)> = + match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -4630,18 +4629,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( "MinimumTardinessSequencing requires --deadlines\n\n\ - Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3 [--precedence-pairs \"0>3,1>3,1>4,2>4\"] [--sizes 3,2,2,1,2]" + Usage: pred create MinimumTardinessSequencing --num-tasks 5 --deadlines 5,5,5,3,3 [--precedences \"0>3,1>3,1>4,2>4\"] [--lengths 3,2,2,1,2]" ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = parse_precedence_pairs(args.precedence_pairs.as_deref())?; + let precedences = + parse_precedence_pairs(args.precedences.as_deref().or(args.precedence_pairs.as_deref()))?; - if let Some(sizes_str) = args.sizes.as_deref() { + if let Some(lengths_str) = args.lengths.as_deref().or(args.sizes.as_deref()) { // Arbitrary-length variant (W = i32) - let lengths: Vec = util::parse_comma_list(sizes_str)?; + let lengths: Vec = util::parse_comma_list(lengths_str)?; anyhow::ensure!( lengths.len() == deadlines.len(), - "sizes length ({}) must equal deadlines length ({})", + "lengths length ({}) must equal deadlines length ({})", lengths.len(), deadlines.len() ); @@ -4656,10 +4656,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } else { // Unit-length variant (W = One) - let num_tasks = args.n.ok_or_else(|| { + let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { anyhow::anyhow!( - "MinimumTardinessSequencing requires --n (number of tasks) or --sizes\n\n\ - Usage: pred create MinimumTardinessSequencing --n 5 --deadlines 5,5,5,3,3" + "MinimumTardinessSequencing requires --num-tasks (number of tasks) or --lengths\n\n\ + Usage: pred create MinimumTardinessSequencing --num-tasks 5 --deadlines 5,5,5,3,3" ) })?; anyhow::ensure!( @@ -4682,15 +4682,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // SchedulingWithIndividualDeadlines "SchedulingWithIndividualDeadlines" => { - let usage = "Usage: pred create SchedulingWithIndividualDeadlines --n 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedence-pairs \"0>3,1>3,1>4,2>4,2>5\"]"; + let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --deadlines, --n, and a processor count (--num-processors or --m)\n\n{usage}" + "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and a processor count (--num-processors or --m)\n\n{usage}" ) })?; - let num_tasks = args.n.ok_or_else(|| { + let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --n (number of tasks)\n\n{usage}" + "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" ) })?; let num_processors = resolve_processor_count_flags( @@ -4705,7 +4705,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences: Vec<(usize, usize)> = match args.precedence_pairs.as_deref() { + let precedences: Vec<(usize, usize)> = + match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -4751,36 +4752,36 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // SequencingToMinimizeTardyTaskWeight "SequencingToMinimizeTardyTaskWeight" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { anyhow::anyhow!( - "SequencingToMinimizeTardyTaskWeight requires --sizes, --weights, and --deadlines\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + "SequencingToMinimizeTardyTaskWeight requires --lengths, --weights, and --deadlines\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" ) })?; let weights_str = args.weights.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeTardyTaskWeight requires --weights\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" ) })?; let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeTardyTaskWeight requires --deadlines\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --sizes 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" ) })?; - let lengths: Vec = util::parse_comma_list(sizes_str)?; + let lengths: Vec = util::parse_comma_list(lengths_str)?; let weights: Vec = util::parse_comma_list(weights_str)?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; anyhow::ensure!( lengths.len() == weights.len(), - "sizes length ({}) must equal weights length ({})", + "lengths length ({}) must equal weights length ({})", lengths.len(), weights.len() ); anyhow::ensure!( lengths.len() == deadlines.len(), - "sizes length ({}) must equal deadlines length ({})", + "lengths length ({}) must equal deadlines length ({})", lengths.len(), deadlines.len() ); @@ -4802,31 +4803,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // SequencingWithDeadlinesAndSetUpTimes "SequencingWithDeadlinesAndSetUpTimes" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { anyhow::anyhow!( - "SequencingWithDeadlinesAndSetUpTimes requires --sizes, --deadlines, --compilers, and --setup-times\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + "SequencingWithDeadlinesAndSetUpTimes requires --lengths, --deadlines, --compilers, and --setup-times\n\n\ + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" ) })?; let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingWithDeadlinesAndSetUpTimes requires --deadlines\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" ) })?; let compilers_str = args.compilers.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingWithDeadlinesAndSetUpTimes requires --compilers\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" ) })?; let setup_times_str = args.setup_times.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingWithDeadlinesAndSetUpTimes requires --setup-times\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --sizes 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" + Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" ) })?; - let lengths: Vec = util::parse_comma_list(sizes_str)?; + let lengths: Vec = util::parse_comma_list(lengths_str)?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; let compilers: Vec = util::parse_comma_list(compilers_str)?; let setup_times: Vec = util::parse_comma_list(setup_times_str)?; @@ -4869,7 +4870,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let lengths_str = args.lengths.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeWeightedCompletionTime requires --lengths and --weights\n\n\ - Usage: pred create SequencingToMinimizeWeightedCompletionTime --lengths 2,1,3,1,2 --weights 3,5,1,4,2 [--precedence-pairs \"0>2,1>4\"]" + Usage: pred create SequencingToMinimizeWeightedCompletionTime --lengths 2,1,3,1,2 --weights 3,5,1,4,2 [--precedences \"0>2,1>4\"]" ) })?; let weights_str = args.weights.as_deref().ok_or_else(|| { @@ -4891,7 +4892,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "task lengths must be positive" ); let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = match args.precedence_pairs.as_deref() { + let precedences: Vec<(usize, usize)> = + match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -4930,45 +4932,45 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // SequencingToMinimizeWeightedTardiness "SequencingToMinimizeWeightedTardiness" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --sizes, --weights, --deadlines, and --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" ) })?; let weights_str = args.weights.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" ) })?; let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" ) })?; let bound = args.bound.ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" ) })?; anyhow::ensure!(bound >= 0, "--bound must be non-negative"); - let lengths: Vec = util::parse_comma_list(sizes_str)?; + let lengths: Vec = util::parse_comma_list(lengths_str)?; let weights: Vec = util::parse_comma_list(weights_str)?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; anyhow::ensure!( lengths.len() == weights.len(), - "sizes length ({}) must equal weights length ({})", + "lengths length ({}) must equal weights length ({})", lengths.len(), weights.len() ); anyhow::ensure!( lengths.len() == deadlines.len(), - "sizes length ({}) must equal deadlines length ({})", + "lengths length ({}) must equal deadlines length ({})", lengths.len(), deadlines.len() ); @@ -4989,11 +4991,12 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let costs_str = args.costs.as_deref().ok_or_else(|| { anyhow::anyhow!( "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ - Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\"" + Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" ) })?; let costs: Vec = util::parse_comma_list(costs_str)?; - let precedences = parse_precedence_pairs(args.precedence_pairs.as_deref())?; + let precedences = + parse_precedence_pairs(args.precedences.as_deref().or(args.precedence_pairs.as_deref()))?; validate_precedence_pairs(&precedences, costs.len())?; ( ser(SequencingToMinimizeMaximumCumulativeCost::new( @@ -5114,9 +5117,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // JobShopScheduling "JobShopScheduling" => { - let usage = "Usage: pred create JobShopScheduling --job-tasks \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; let job_tasks = args.job_tasks.as_deref().ok_or_else(|| { - anyhow::anyhow!("JobShopScheduling requires --job-tasks\n\n{usage}") + anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}") })?; let jobs = parse_job_shop_jobs(job_tasks)?; let inferred_processors = jobs @@ -5519,7 +5522,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // AcyclicPartition "AcyclicPartition" => { - let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; + let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-weights 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; let arcs_str = args .arcs .as_deref() @@ -5537,7 +5540,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { bail!("AcyclicPartition --weights must be positive (Z+)"); } if arc_costs.iter().any(|&cost| cost <= 0) { - bail!("AcyclicPartition --arc-costs must be positive (Z+)"); + bail!("AcyclicPartition --arc-weights must be positive (Z+)"); } if weight_bound <= 0 { bail!("AcyclicPartition --weight-bound must be positive (Z+)"); @@ -6030,12 +6033,12 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // MixedChinesePostman "MixedChinesePostman" => { - let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 [--num-vertices N]"; + let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; let graph = parse_mixed_graph(args, usage)?; let arc_costs = parse_arc_costs(args, graph.num_arcs())?; let edge_weights = parse_edge_weights(args, graph.num_edges())?; if arc_costs.iter().any(|&cost| cost < 0) { - bail!("MixedChinesePostman --arc-costs must be non-negative\n\n{usage}"); + bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); } if edge_weights.iter().any(|&weight| weight < 0) { bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); @@ -6046,7 +6049,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { { bail!( "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ - Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-costs ..." + Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." ); } ( @@ -6528,20 +6531,20 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "PrimeAttributeName" => { let universe = args.universe.ok_or_else(|| { anyhow::anyhow!( - "PrimeAttributeName requires --universe, --deps, and --query\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --deps \"0,1>2,3,4,5;2,3>0,1,4,5\" --query 3" + "PrimeAttributeName requires --universe, --dependencies, and --query-attribute\n\n\ + Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" ) })?; - let deps_str = args.deps.as_deref().ok_or_else(|| { + let deps_str = args.dependencies.as_deref().or(args.deps.as_deref()).ok_or_else(|| { anyhow::anyhow!( - "PrimeAttributeName requires --deps\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --deps \"0,1>2,3,4,5;2,3>0,1,4,5\" --query 3" + "PrimeAttributeName requires --dependencies\n\n\ + Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" ) })?; let query = args.query.ok_or_else(|| { anyhow::anyhow!( - "PrimeAttributeName requires --query\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --deps \"0,1>2,3,4,5;2,3>0,1,4,5\" --query 3" + "PrimeAttributeName requires --query-attribute\n\n\ + Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" ) })?; let dependencies = parse_deps(deps_str)?; @@ -7407,10 +7410,10 @@ fn parse_disjuncts(args: &CreateArgs) -> Result>> { .collect() } -/// Parse `--sets` as semicolon-separated sets of comma-separated usize. +/// Parse `--subsets` as semicolon-separated sets of comma-separated usize. /// E.g., "0,1;1,2;0,2" fn parse_sets(args: &CreateArgs) -> Result>> { - parse_named_sets(args.sets.as_deref(), "--sets") + parse_named_sets(args.sets.as_deref(), "--subsets") } fn parse_named_sets(sets_str: Option<&str>, flag: &str) -> Result>> { @@ -7635,7 +7638,7 @@ fn parse_bundles(args: &CreateArgs, num_arcs: usize, usage: &str) -> Result Result { let raw_bound = args .bound - .ok_or_else(|| anyhow::anyhow!("MultipleChoiceBranching requires --bound\n\n{usage}"))?; + .ok_or_else(|| anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}"))?; anyhow::ensure!( raw_bound >= 0, "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" @@ -8036,7 +8039,9 @@ fn parse_i64_matrix(s: &str) -> Result>> { fn parse_potential_edges(args: &CreateArgs) -> Result> { let edges_str = args.potential_edges.as_deref().ok_or_else(|| { - anyhow::anyhow!("BiconnectivityAugmentation requires --potential-edges (e.g., 0-2:3,1-3:5)") + anyhow::anyhow!( + "BiconnectivityAugmentation requires --potential-weights (e.g., 0-2:3,1-3:5)" + ) })?; edges_str @@ -8219,7 +8224,7 @@ fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { } } -/// Parse `--arc-costs` as per-arc costs (i32), defaulting to all 1s. +/// Parse `--arc-weights` / `--arc-lengths` as per-arc costs (i32), defaulting to all 1s. fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { match &args.arc_costs { Some(costs) => { @@ -8815,7 +8820,7 @@ mod tests { fn test_problem_help_uses_bound_for_length_bounded_disjoint_paths() { assert_eq!( problem_help_flag_name("LengthBoundedDisjointPaths", "max_length", "usize", false), - "bound" + "max-length" ); } @@ -8855,7 +8860,7 @@ mod tests { "pred", "create", "SchedulingWithIndividualDeadlines", - "--n", + "--num-tasks", "3", "--deadlines", "1,1,2", @@ -8888,6 +8893,47 @@ mod tests { assert_eq!(created["data"]["num_processors"], 2); } + #[test] + fn test_create_prime_attribute_name_accepts_canonical_flags() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PrimeAttributeName", + "--universe", + "6", + "--dependencies", + "0,1>2,3,4,5;2,3>0,1,4,5", + "--query-attribute", + "3", + ]) + .expect("parse create command"); + + let Commands::Create(args) = cli.command else { + panic!("expected create subcommand"); + }; + + let output_path = temp_output_path("prime_attribute_name"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create PrimeAttributeName JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "PrimeAttributeName"); + assert_eq!(created["data"]["query_attribute"], 3); + assert_eq!( + created["data"]["dependencies"][0], + serde_json::json!([[0, 1], [2, 3, 4, 5]]) + ); + } + #[test] fn test_problem_help_uses_prime_attribute_name_cli_overrides() { assert_eq!( @@ -8901,11 +8947,11 @@ mod tests { "Vec<(Vec, Vec)>", false, ), - "deps" + "dependencies" ); assert_eq!( problem_help_flag_name("PrimeAttributeName", "query_attribute", "usize", false), - "query" + "query-attribute" ); } @@ -10498,7 +10544,7 @@ mod tests { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("JobShopScheduling requires --job-tasks")); + assert!(err.contains("JobShopScheduling requires --jobs")); } #[test] diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 448f86c7..e0320130 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -153,12 +153,12 @@ fn test_create_stacker_crane_schema_help_uses_documented_flags() { assert!(stderr.contains("StackerCrane"), "stderr: {stderr}"); assert!(stderr.contains("--arcs"), "stderr: {stderr}"); assert!(stderr.contains("--graph"), "stderr: {stderr}"); - assert!(stderr.contains("--arc-costs"), "stderr: {stderr}"); + assert!(stderr.contains("--arc-lengths"), "stderr: {stderr}"); assert!(stderr.contains("--edge-lengths"), "stderr: {stderr}"); assert!(stderr.contains("--num-vertices"), "stderr: {stderr}"); assert!(!stderr.contains("--bound"), "stderr: {stderr}"); assert!(!stderr.contains("--biedges"), "stderr: {stderr}"); - assert!(!stderr.contains("--arc-lengths"), "stderr: {stderr}"); + assert!(!stderr.contains("--arc-weights"), "stderr: {stderr}"); assert!(!stderr.contains("--edge-weights"), "stderr: {stderr}"); } @@ -1855,10 +1855,10 @@ fn test_create_comparative_containment_no_flags_shows_help() { "should exit non-zero when showing help without data flags" ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--universe"), "stderr: {stderr}"); + assert!(stderr.contains("--universe-size"), "stderr: {stderr}"); assert!(stderr.contains("--r-sets"), "stderr: {stderr}"); assert!(stderr.contains("--s-sets"), "stderr: {stderr}"); - assert!(!stderr.contains("--universe-size"), "stderr: {stderr}"); + assert!(!stderr.contains("--universe "), "stderr: {stderr}"); } #[test] @@ -1936,7 +1936,7 @@ fn test_create_help_lists_minimum_hitting_set_flags() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("MinimumHittingSet") && stdout.contains("--universe, --sets"), + stdout.contains("MinimumHittingSet") && stdout.contains("--universe-size, --subsets"), "stdout: {stdout}" ); } @@ -2030,7 +2030,7 @@ fn test_create_sequencing_to_minimize_weighted_tardiness_rejects_mismatched_leng .args([ "create", "SequencingToMinimizeWeightedTardiness", - "--sizes", + "--lengths", "3,4,2", "--weights", "2,3", @@ -2044,7 +2044,7 @@ fn test_create_sequencing_to_minimize_weighted_tardiness_rejects_mismatched_leng assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("sizes length (3) must equal weights length (2)"), + stderr.contains("lengths length (3) must equal weights length (2)"), "stderr: {stderr}" ); } @@ -3316,14 +3316,14 @@ fn test_create_bounded_component_spanning_forest_rejects_negative_bound() { "1,1,1,1", "--k", "2", - "--bound", + "--max-weight", "-1", ]) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("positive --bound"), "stderr: {stderr}"); + assert!(stderr.contains("positive --max-weight"), "stderr: {stderr}"); } #[test] @@ -3364,17 +3364,13 @@ fn test_create_bounded_component_spanning_forest_no_flags_shows_actual_cli_flags "expected '--k' in help output, got: {stderr}" ); assert!( - stderr.contains("--bound"), - "expected '--bound' in help output, got: {stderr}" + stderr.contains("--max-weight"), + "expected '--max-weight' in help output, got: {stderr}" ); assert!( !stderr.contains("--max-components"), "help should not advertise nonexistent '--max-components' flag: {stderr}" ); - assert!( - !stderr.contains("--max-weight"), - "help should not advertise nonexistent '--max-weight' flag: {stderr}" - ); } #[test] @@ -3989,7 +3985,7 @@ fn test_create_sequencing_to_minimize_weighted_tardiness_no_flags_shows_help() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--sizes")); + assert!(stderr.contains("--lengths")); assert!(stderr.contains("--weights")); assert!(stderr.contains("--deadlines")); assert!(stderr.contains("--bound")); @@ -3997,7 +3993,7 @@ fn test_create_sequencing_to_minimize_weighted_tardiness_no_flags_shows_help() { } #[test] -fn test_create_multiple_choice_branching_help_uses_bound_flag() { +fn test_create_multiple_choice_branching_help_uses_threshold_flag() { let output = pred() .args(["create", "MultipleChoiceBranching/i32"]) .output() @@ -4005,12 +4001,12 @@ fn test_create_multiple_choice_branching_help_uses_bound_flag() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains("--bound"), - "expected '--bound' in help output, got: {stderr}" + stderr.contains("--threshold"), + "expected '--threshold' in help output, got: {stderr}" ); assert!( - !stderr.contains("--threshold"), - "help output should not advertise '--threshold', got: {stderr}" + !stderr.contains("--bound"), + "help output should not advertise '--bound', got: {stderr}" ); assert!( stderr.contains("semicolon-separated groups"), @@ -4024,21 +4020,17 @@ fn test_create_set_basis_no_flags_uses_actual_cli_flag_names() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--universe"), - "expected '--universe' in help output, got: {stderr}" + stderr.contains("--universe-size"), + "expected '--universe-size' in help output, got: {stderr}" ); assert!( - stderr.contains("--sets"), - "expected '--sets' in help output, got: {stderr}" + stderr.contains("--subsets"), + "expected '--subsets' in help output, got: {stderr}" ); assert!( stderr.contains("--k"), "expected '--k' in help output, got: {stderr}" ); - assert!( - !stderr.contains("--universe-size"), - "help should not advertise schema field names: {stderr}" - ); assert!( !stderr.contains("--collection"), "help should not advertise schema field names: {stderr}" @@ -4144,7 +4136,7 @@ fn test_create_help_uses_generic_matrix_and_k_descriptions() { } #[test] -fn test_create_length_bounded_disjoint_paths_help_uses_bound_flag() { +fn test_create_length_bounded_disjoint_paths_help_uses_max_length_flag() { let output = pred() .args(["create", "LengthBoundedDisjointPaths"]) .output() @@ -4152,12 +4144,12 @@ fn test_create_length_bounded_disjoint_paths_help_uses_bound_flag() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--bound"), - "expected '--bound' in help output, got: {stderr}" + stderr.contains("--max-length"), + "expected '--max-length' in help output, got: {stderr}" ); assert!( - !stderr.contains("--max-length"), - "help should advertise the actual CLI flag name, got: {stderr}" + !stderr.contains("--bound"), + "help should advertise the canonical CLI flag name, got: {stderr}" ); } @@ -4196,24 +4188,24 @@ fn test_create_prime_attribute_name_no_flags_uses_actual_cli_flag_names() { "expected '--universe' in help output, got: {stderr}" ); assert!( - stderr.contains("--deps"), - "expected '--deps' in help output, got: {stderr}" + stderr.contains("--dependencies"), + "expected '--dependencies' in help output, got: {stderr}" ); assert!( - stderr.contains("--query"), - "expected '--query' in help output, got: {stderr}" + stderr.contains("--query-attribute"), + "expected '--query-attribute' in help output, got: {stderr}" ); assert!( !stderr.contains("--num-attributes"), "help should not advertise schema field names: {stderr}" ); assert!( - !stderr.contains("--dependencies"), - "help should not advertise schema field names: {stderr}" + !stderr.contains("--deps"), + "help should not advertise the legacy flag name: {stderr}" ); assert!( - !stderr.contains("--query-attribute"), - "help should not advertise schema field names: {stderr}" + !stderr.contains("--query\n"), + "help should not advertise the legacy flag name: {stderr}" ); } @@ -4567,7 +4559,7 @@ fn test_create_length_bounded_disjoint_paths_rejects_negative_bound_value() { "0", "--sink", "1", - "--bound", + "--max-length", "-1", ]) .output() @@ -4575,7 +4567,7 @@ fn test_create_length_bounded_disjoint_paths_rejects_negative_bound_value() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--bound must be a nonnegative integer for LengthBoundedDisjointPaths"), + stderr.contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), "expected user-facing negative-bound error, got: {stderr}" ); } @@ -4591,14 +4583,14 @@ fn test_create_random_length_bounded_disjoint_paths_rejects_negative_bound_value "3", "--seed", "7", - "--bound=-1", + "--max-length=-1", ]) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--bound must be a nonnegative integer for LengthBoundedDisjointPaths"), + stderr.contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), "expected shared negative-bound validation, got: {stderr}" ); } @@ -7023,7 +7015,7 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_invalid_precedence "SequencingToMinimizeMaximumCumulativeCost", "--costs", "1,-1,2", - "--precedence-pairs", + "--precedences", "a>b", "--bound", "2", @@ -7033,7 +7025,7 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_invalid_precedence assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--precedence-pairs"), + stderr.contains("--precedences"), "expected flag-specific precedence parse error, got: {stderr}" ); } @@ -8227,20 +8219,20 @@ fn test_create_ensemble_computation_no_flags_uses_cli_flag_names() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--universe"), - "expected --universe in help, got: {stderr}" + stderr.contains("--universe-size"), + "expected --universe-size in help, got: {stderr}" ); assert!( - stderr.contains("--sets"), - "expected --sets in help, got: {stderr}" + stderr.contains("--subsets"), + "expected --subsets in help, got: {stderr}" ); assert!( stderr.contains("--budget"), "expected --budget in help, got: {stderr}" ); assert!( - !stderr.contains("--universe-size"), - "help should use actual CLI flags, got: {stderr}" + !stderr.contains("--universe "), + "help should use canonical CLI flags, got: {stderr}" ); } From b9a9f76ed8c30a8a70ea4e7e0bccb81fc983cdc7 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 12:25:00 +0800 Subject: [PATCH 04/13] Phase 4: add CreateArgs flag map --- problemreductions-cli/src/cli.rs | 300 +++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a6e994ca..cfe0043d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,4 +1,5 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +use std::collections::HashMap; use std::path::PathBuf; #[derive(Parser)] @@ -914,6 +915,228 @@ pub struct CreateArgs { pub num_colors: Option, } +impl CreateArgs { + #[allow(dead_code)] + pub fn flag_map(&self) -> HashMap<&'static str, Option> { + let mut flags = HashMap::new(); + + macro_rules! insert { + ($key:literal, $expr:expr) => { + flags.insert($key, ($expr).map(|value| value.to_string())); + }; + } + + insert!("example", self.example.as_deref()); + insert!("to", self.example_target.as_deref()); + insert!("graph", self.graph.as_deref()); + insert!("weights", self.weights.as_deref()); + insert!("edge-weights", self.edge_weights.as_deref()); + insert!("edge-lengths", self.edge_lengths.as_deref()); + insert!("capacities", self.capacities.as_deref()); + insert!("demands", self.demands.as_deref()); + insert!("setup-costs", self.setup_costs.as_deref()); + insert!("production-costs", self.production_costs.as_deref()); + insert!("inventory-costs", self.inventory_costs.as_deref()); + insert!("bundle-capacities", self.bundle_capacities.as_deref()); + insert!("cost-matrix", self.cost_matrix.as_deref()); + insert!("delay-matrix", self.delay_matrix.as_deref()); + insert!("lower-bounds", self.lower_bounds.as_deref()); + insert!("multipliers", self.multipliers.as_deref()); + insert!("sink", self.sink); + insert!("requirement", self.requirement); + insert!("num-paths-required", self.num_paths_required); + insert!("paths", self.paths.as_deref()); + insert!("couplings", self.couplings.as_deref()); + insert!("fields", self.fields.as_deref()); + insert!("clauses", self.clauses.as_deref()); + insert!("disjuncts", self.disjuncts.as_deref()); + insert!("num-vars", self.num_vars); + insert!("matrix", self.matrix.as_deref()); + insert!("k", self.k); + insert!("num-partitions", self.num_partitions); + flags.insert("random", self.random.then(|| "true".to_string())); + insert!("num-vertices", self.num_vertices); + insert!("source-vertex", self.source_vertex); + insert!("target-vertex", self.target_vertex); + insert!("edge-prob", self.edge_prob); + insert!("seed", self.seed); + insert!("target", self.target.as_deref()); + insert!("m", self.m); + insert!("n", self.n); + insert!("positions", self.positions.as_deref()); + insert!("radius", self.radius); + insert!("source-1", self.source_1); + insert!("sink-1", self.sink_1); + insert!("source-2", self.source_2); + insert!("sink-2", self.sink_2); + insert!("requirement-1", self.requirement_1); + insert!("requirement-2", self.requirement_2); + insert!("sizes", self.sizes.as_deref()); + insert!("probabilities", self.probabilities.as_deref()); + insert!("capacity", self.capacity.as_deref()); + insert!("sequence", self.sequence.as_deref()); + insert!("subsets", self.sets.as_deref()); + insert!("sets", self.sets.as_deref()); + insert!("r-sets", self.r_sets.as_deref()); + insert!("s-sets", self.s_sets.as_deref()); + insert!("r-weights", self.r_weights.as_deref()); + insert!("s-weights", self.s_weights.as_deref()); + insert!("partition", self.partition.as_deref()); + insert!("partitions", self.partitions.as_deref()); + insert!("bundles", self.bundles.as_deref()); + insert!("universe-size", self.universe); + insert!("universe", self.universe); + insert!("biedges", self.biedges.as_deref()); + insert!("left", self.left); + insert!("right", self.right); + insert!("rank", self.rank); + insert!("basis", self.basis.as_deref()); + insert!("target-vec", self.target_vec.as_deref()); + insert!("bounds", self.bounds.as_deref()); + insert!("release-times", self.release_times.as_deref()); + insert!("lengths", self.lengths.as_deref().or(self.sizes.as_deref())); + insert!("terminals", self.terminals.as_deref()); + insert!("terminal-pairs", self.terminal_pairs.as_deref()); + insert!("tree", self.tree.as_deref()); + insert!("required-edges", self.required_edges.as_deref()); + insert!("bound", self.bound); + insert!("max-length", self.bound); + insert!("max-weight", self.bound); + insert!("bound-k", self.bound); + insert!("threshold", self.bound); + insert!("latency-bound", self.latency_bound); + insert!("length-bound", self.length_bound); + insert!("weight-bound", self.weight_bound); + insert!("diameter-bound", self.diameter_bound); + insert!("cost-bound", self.cost_bound); + insert!("delay-budget", self.delay_budget); + insert!("pattern", self.pattern.as_deref()); + insert!("strings", self.strings.as_deref()); + insert!("string", self.string.as_deref()); + insert!("costs", self.costs.as_deref()); + insert!("arc-weights", self.arc_costs.as_deref()); + insert!("arc-costs", self.arc_costs.as_deref()); + insert!("arc-lengths", self.arc_costs.as_deref()); + insert!("arcs", self.arcs.as_deref()); + insert!("left-arcs", self.left_arcs.as_deref()); + insert!("right-arcs", self.right_arcs.as_deref()); + insert!("homologous-pairs", self.homologous_pairs.as_deref()); + insert!("quantifiers", self.quantifiers.as_deref()); + insert!("size-bound", self.size_bound); + insert!("cut-bound", self.cut_bound); + insert!("values", self.values.as_deref()); + insert!( + "precedences", + self.precedences + .as_deref() + .or(self.precedence_pairs.as_deref()) + ); + insert!( + "precedence-pairs", + self.precedences + .as_deref() + .or(self.precedence_pairs.as_deref()) + ); + insert!("distance-matrix", self.distance_matrix.as_deref()); + insert!("potential-weights", self.potential_edges.as_deref()); + insert!("potential-edges", self.potential_edges.as_deref()); + insert!("budget", self.budget.as_deref()); + insert!("max-cycle-length", self.max_cycle_length); + insert!("candidate-arcs", self.candidate_arcs.as_deref()); + insert!("usage", self.usage.as_deref()); + insert!("storage", self.storage.as_deref()); + insert!("deadlines", self.deadlines.as_deref()); + insert!("resource-bounds", self.resource_bounds.as_deref()); + insert!( + "resource-requirements", + self.resource_requirements.as_deref() + ); + insert!("task-lengths", self.task_lengths.as_deref()); + insert!("jobs", self.job_tasks.as_deref()); + insert!("job-tasks", self.job_tasks.as_deref()); + insert!("deadline", self.deadline); + insert!("num-processors", self.num_processors); + insert!("schedules", self.schedules.as_deref()); + insert!("requirements", self.requirements.as_deref()); + insert!("num-workers", self.num_workers); + insert!("num-periods", self.num_periods); + insert!("num-craftsmen", self.num_craftsmen); + insert!("num-tasks", self.num_tasks.or(self.n)); + insert!("craftsman-avail", self.craftsman_avail.as_deref()); + insert!("task-avail", self.task_avail.as_deref()); + insert!("alphabet-size", self.alphabet_size); + insert!("num-attributes", self.num_attributes); + insert!( + "dependencies", + self.dependencies.as_deref().or(self.deps.as_deref()) + ); + insert!("deps", self.dependencies.as_deref().or(self.deps.as_deref())); + insert!("relation-attrs", self.relation_attrs.as_deref()); + insert!("known-keys", self.known_keys.as_deref()); + insert!("num-objects", self.num_objects); + insert!("attribute-domains", self.attribute_domains.as_deref()); + insert!("frequency-tables", self.frequency_tables.as_deref()); + insert!("known-values", self.known_values.as_deref()); + insert!("domain-size", self.domain_size); + insert!("relations", self.relations.as_deref()); + insert!("conjuncts-spec", self.conjuncts_spec.as_deref()); + insert!("query-attribute", self.query); + insert!("query", self.query); + insert!("rhs", self.rhs.as_deref()); + insert!("required-columns", self.required_columns.as_deref()); + insert!("num-groups", self.num_groups); + insert!("num-sectors", self.num_sectors); + insert!("compilers", self.compilers.as_deref()); + insert!("setup-times", self.setup_times.as_deref()); + insert!("source-string", self.source_string.as_deref()); + insert!("target-string", self.target_string.as_deref()); + insert!("pointer-cost", self.pointer_cost); + insert!("expression", self.expression.as_deref()); + insert!("equations", self.equations.as_deref()); + insert!("assignment", self.assignment.as_deref()); + insert!("coeff-a", self.coeff_a); + insert!("coeff-b", self.coeff_b); + insert!("coeff-c", self.coeff_c); + insert!("pairs", self.pairs.as_deref()); + insert!("w-sizes", self.w_sizes.as_deref()); + insert!("x-sizes", self.x_sizes.as_deref()); + insert!("y-sizes", self.y_sizes.as_deref()); + insert!("initial-marking", self.initial_marking.as_deref()); + insert!("output-arcs", self.output_arcs.as_deref()); + insert!("gate-types", self.gate_types.as_deref()); + insert!("inputs", self.inputs.as_deref()); + insert!("outputs", self.outputs.as_deref()); + insert!("true-sentences", self.true_sentences.as_deref()); + insert!("implications", self.implications.as_deref()); + insert!("loop-length", self.loop_length); + insert!("loop-variables", self.loop_variables.as_deref()); + insert!("assignments", self.assignments.as_deref()); + insert!("num-variables", self.num_variables); + insert!("truth-table", self.truth_table.as_deref()); + insert!("test-matrix", self.test_matrix.as_deref()); + insert!("num-tests", self.num_tests); + insert!("tiles", self.tiles.as_deref()); + insert!("grid-size", self.grid_size); + insert!("num-colors", self.num_colors); + + flags.insert( + "source", + self.source_string + .clone() + .or_else(|| self.source.map(|value| value.to_string())), + ); + flags.insert( + "target", + self.target_string + .clone() + .or_else(|| self.target.clone()) + .or_else(|| self.sink.map(|value| value.to_string())), + ); + + flags + } +} + #[derive(clap::Args)] #[command(after_help = "\ Examples: @@ -1182,6 +1405,83 @@ mod tests { assert_eq!(args.query, Some(3)); } + #[test] + fn test_create_args_flag_map_prefers_canonical_prime_attribute_keys() { + let cli = Cli::parse_from([ + "pred", + "create", + "PrimeAttributeName", + "--universe", + "6", + "--dependencies", + "0,1>2,3,4,5;2,3>0,1,4,5", + "--query-attribute", + "3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let flags = args.flag_map(); + assert_eq!(flags.get("universe-size"), Some(&Some("6".to_string()))); + assert_eq!( + flags.get("dependencies"), + Some(&Some("0,1>2,3,4,5;2,3>0,1,4,5".to_string())) + ); + assert_eq!(flags.get("query-attribute"), Some(&Some("3".to_string()))); + } + + #[test] + fn test_create_args_flag_map_converts_numeric_and_alias_backed_values() { + let cli = Cli::parse_from([ + "pred", + "create", + "LengthBoundedDisjointPaths", + "--graph", + "0-1,1-2,2-3", + "--source", + "0", + "--sink", + "3", + "--max-length", + "4", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let flags = args.flag_map(); + assert_eq!(flags.get("source"), Some(&Some("0".to_string()))); + assert_eq!(flags.get("sink"), Some(&Some("3".to_string()))); + assert_eq!(flags.get("max-length"), Some(&Some("4".to_string()))); + assert_eq!(flags.get("bound"), Some(&Some("4".to_string()))); + } + + #[test] + fn test_create_args_flag_map_promotes_legacy_jobs_alias_to_canonical_key() { + let cli = Cli::parse_from([ + "pred", + "create", + "JobShopScheduling", + "--job-tasks", + "0:3,1:4;1:2,0:3,1:2", + "--num-processors", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let flags = args.flag_map(); + assert_eq!( + flags.get("jobs"), + Some(&Some("0:3,1:4;1:2,0:3,1:2".to_string())) + ); + } + #[test] fn test_create_parses_partial_feedback_edge_set_flags() { let cli = Cli::parse_from([ From 87886559b7cc7db147cfe9270f652c0f3ded921f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 12:33:02 +0800 Subject: [PATCH 05/13] Phase 2: add schema field parser registry --- problemreductions-cli/src/commands/create.rs | 503 +++++++++++++++++++ 1 file changed, 503 insertions(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 71551cc3..23fcaba0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -6,6 +6,7 @@ use crate::problem_name::{ }; use crate::util; use anyhow::{bail, Context, Result}; +use num_bigint::BigUint; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ AlgebraicEquationsOverGF2, ClosestVectorProblem, ConsecutiveBlockMinimization, @@ -598,6 +599,439 @@ fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { emit_problem_output(&output, out) } +#[derive(Debug, Clone, Default)] +struct CreateContext { + num_vertices: Option, + num_edges: Option, + num_arcs: Option, + parsed_fields: BTreeMap, +} + +impl CreateContext { + fn with_field(mut self, name: &str, value: serde_json::Value) -> Self { + self.parsed_fields.insert(name.to_string(), value); + self + } + + fn usize_field(&self, name: &str) -> Option { + self.parsed_fields + .get(name) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + } + + fn f64_field(&self, name: &str) -> Option { + self.parsed_fields + .get(name) + .and_then(serde_json::Value::as_f64) + } +} + +fn parse_field_value( + concrete_type: &str, + field_name: &str, + raw: &str, + context: &CreateContext, +) -> Result { + let normalized_type = normalize_type_name(concrete_type); + let value = match normalized_type.as_str() { + "SimpleGraph" => parse_simple_graph_value(raw, context)?, + "DirectedGraph" => parse_directed_graph_value(raw, context)?, + "KingsSubgraph" => parse_grid_subgraph_value(raw, true)?, + "TriangularSubgraph" => parse_grid_subgraph_value(raw, false)?, + "UnitDiskGraph" => parse_unit_disk_graph_value(raw, context)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_bool_list_value(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => serde_json::to_value(parse_bool_rows(raw)?)?, + "Vec>>" => parse_3d_numeric_list_value::(raw)?, + "Vec>>" => parse_3d_numeric_list_value::(raw)?, + "Vec<[usize;3]>" => parse_triple_array_list_value(raw)?, + "Vec" => serde_json::to_value(parse_clauses_raw(raw)?)?, + "Vec<(usize,usize)>" => parse_pair_list_value(raw)?, + "Vec<(u64,u64)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,f64)>" => parse_indexed_numeric_pairs_value::(raw)?, + "Vec<(usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,usize,i32)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,i64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,u64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,f64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(Vec,Vec)>" => serde_json::to_value(parse_dependencies(raw)?)?, + "Vec<(Vec,usize)>" => serde_json::to_value(parse_implications(raw)?)?, + "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, + "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, + "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, + "Vec" => parse_string_list_value(raw)?, + "Vec" => parse_biguint_list_value(raw)?, + "BigUint" => parse_biguint_value(raw)?, + "Vec>" => parse_optional_bool_list_value(raw)?, + "Vec" => serde_json::to_value(parse_quantifiers_raw(raw, context)?)?, + "IntExpr" => parse_json_passthrough_value(raw)?, + "bool" => serde_json::to_value(parse_bool_token(raw.trim())?)?, + "One" => serde_json::json!(1), + "usize" => parse_scalar_value::(raw)?, + "u64" => parse_scalar_value::(raw)?, + "i32" => parse_scalar_value::(raw)?, + "i64" => parse_scalar_value::(raw)?, + "f64" => parse_scalar_value::(raw)?, + other => bail!( + "Unsupported schema parser for field '{field_name}' with type '{other}'" + ), + }; + + Ok(value) +} + +fn normalize_type_name(type_name: &str) -> String { + type_name.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +fn parse_scalar_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + Ok(serde_json::to_value(raw.trim().parse::().map_err( + |err| anyhow::anyhow!("Invalid value '{}': {err}", raw.trim()), + )?)?) +} + +fn parse_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + Ok(serde_json::to_value(util::parse_comma_list::(raw)?)?) +} + +fn parse_bool_list_value(raw: &str) -> Result { + let values: Vec = raw + .split(',') + .map(|entry| parse_bool_token(entry.trim())) + .collect::>()?; + Ok(serde_json::to_value(values)?) +} + +fn parse_nested_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let rows: Vec> = raw + .split(';') + .map(|row| util::parse_comma_list::(row.trim())) + .collect::>()?; + Ok(serde_json::to_value(rows)?) +} + +fn parse_3d_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let matrices: Vec>> = raw + .split('|') + .map(|matrix| { + matrix + .split(';') + .map(|row| util::parse_comma_list::(row.trim())) + .collect::>>() + }) + .collect::>()?; + Ok(serde_json::to_value(matrices)?) +} + +fn parse_triple_array_list_value(raw: &str) -> Result { + let triples: Vec<[usize; 3]> = raw + .split(';') + .map(|entry| { + let values: Vec = util::parse_comma_list(entry.trim())?; + anyhow::ensure!( + values.len() == 3, + "Expected triple with exactly 3 entries, got {}", + values.len() + ); + Ok([values[0], values[1], values[2]]) + }) + .collect::>()?; + Ok(serde_json::to_value(triples)?) +} + +fn parse_clauses_raw(raw: &str) -> Result> { + raw.split(';') + .map(|clause| { + let literals: Vec = clause + .trim() + .split(',') + .map(|value| value.trim().parse::()) + .collect::, _>>()?; + Ok(CNFClause::new(literals)) + }) + .collect() +} + +fn parse_pair_list_value(raw: &str) -> Result { + let pairs: Vec<(usize, usize)> = raw + .split(',') + .map(|entry| { + let entry = entry.trim(); + let parts: Vec<&str> = if entry.contains('>') { + entry.split('>').collect() + } else { + entry.split('-').collect() + }; + anyhow::ensure!( + parts.len() == 2, + "Invalid pair '{entry}': expected u-v or u>v" + ); + Ok(( + parts[0].trim().parse::()?, + parts[1].trim().parse::()?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(pairs)?) +} + +fn parse_semicolon_tuple_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let tuples: Vec> = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let values: Vec = util::parse_comma_list(entry.trim())?; + anyhow::ensure!( + values.len() == N, + "Expected tuple with {N} entries, got {}", + values.len() + ); + Ok(values) + }) + .collect::>()?; + Ok(serde_json::to_value(tuples)?) +} + +fn parse_weighted_edge_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let edges: Vec<(usize, usize, T)> = raw + .split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (edge_part, weight_part) = entry + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("Invalid weighted edge '{entry}': expected u-v:w"))?; + let (u_str, v_str) = if let Some((u, v)) = edge_part.split_once('-') { + (u, v) + } else if let Some((u, v)) = edge_part.split_once('>') { + (u, v) + } else { + bail!("Invalid weighted edge '{entry}': expected u-v:w or u>v:w"); + }; + Ok(( + u_str.trim().parse::()?, + v_str.trim().parse::()?, + weight_part.trim().parse::().map_err(|err| { + anyhow::anyhow!("Invalid edge weight '{}': {err}", weight_part.trim()) + })?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(edges)?) +} + +fn parse_indexed_numeric_pairs_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let pairs: Vec<(usize, T)> = raw + .split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (index, value) = entry + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("Invalid pair '{entry}': expected index:value"))?; + Ok(( + index.trim().parse::()?, + value + .trim() + .parse::() + .map_err(|err| anyhow::anyhow!("Invalid value '{}': {err}", value.trim()))?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(pairs)?) +} + +fn parse_indexed_usize_lists_value(raw: &str) -> Result { + let entries: Vec<(usize, Vec)> = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (index, values) = entry + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("Invalid entry '{entry}': expected index:values"))?; + Ok(( + index.trim().parse::()?, + if values.trim().is_empty() { + Vec::new() + } else { + util::parse_comma_list(values.trim())? + }, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(entries)?) +} + +fn parse_string_list_value(raw: &str) -> Result { + let values: Vec = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| entry.trim().to_string()) + .collect(); + Ok(serde_json::to_value(values)?) +} + +fn parse_biguint_list_value(raw: &str) -> Result { + let values: Vec = util::parse_biguint_list(raw)? + .into_iter() + .map(|value| value.to_string()) + .collect(); + Ok(serde_json::to_value(values)?) +} + +fn parse_biguint_value(raw: &str) -> Result { + let value: BigUint = util::parse_decimal_biguint(raw)?; + Ok(serde_json::Value::String(value.to_string())) +} + +fn parse_optional_bool_list_value(raw: &str) -> Result { + let values: Vec> = raw + .split(',') + .map(|entry| { + let entry = entry.trim(); + match entry { + "?" => Ok(None), + _ => Ok(Some(parse_bool_token(entry)?)), + } + }) + .collect::>()?; + Ok(serde_json::to_value(values)?) +} + +fn parse_quantifiers_raw(raw: &str, context: &CreateContext) -> Result> { + let quantifiers: Vec = raw + .split(',') + .map(|entry| match entry.trim().to_lowercase().as_str() { + "e" | "exists" => Ok(Quantifier::Exists), + "a" | "forall" => Ok(Quantifier::ForAll), + other => Err(anyhow::anyhow!( + "Invalid quantifier '{}': expected E/Exists or A/ForAll", + other + )), + }) + .collect::>()?; + + if let Some(num_vars) = context.usize_field("num_vars") { + anyhow::ensure!( + quantifiers.len() == num_vars, + "Expected {num_vars} quantifiers but got {}", + quantifiers.len() + ); + } + + Ok(quantifiers) +} + +fn parse_json_passthrough_value(raw: &str) -> Result { + serde_json::from_str(raw).context("Invalid JSON input") +} + +fn parse_bool_token(raw: &str) -> Result { + match raw.trim() { + "1" | "true" | "TRUE" | "True" => Ok(true), + "0" | "false" | "FALSE" | "False" => Ok(false), + other => bail!("Invalid boolean entry '{other}': expected 0/1 or true/false"), + } +} + +fn parse_simple_graph_value(raw: &str, context: &CreateContext) -> Result { + let raw = raw.trim(); + let num_vertices = context.usize_field("num_vertices").or(context.num_vertices); + let graph = if raw.is_empty() { + let num_vertices = num_vertices.ok_or_else(|| { + anyhow::anyhow!( + "Empty graph string. To create a graph with isolated vertices, provide num_vertices first." + ) + })?; + SimpleGraph::empty(num_vertices) + } else { + let edges = util::parse_edge_pairs(raw)?; + let inferred_num_vertices = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|max_vertex| max_vertex + 1) + .unwrap_or(0); + let num_vertices = match num_vertices { + Some(explicit) => { + anyhow::ensure!( + explicit >= inferred_num_vertices, + "num_vertices ({explicit}) is too small for the graph: need at least {inferred_num_vertices}" + ); + explicit + } + None => inferred_num_vertices, + }; + SimpleGraph::new(num_vertices, edges) + }; + Ok(serde_json::to_value(graph)?) +} + +fn parse_directed_graph_value(raw: &str, context: &CreateContext) -> Result { + let (graph, _) = parse_directed_graph( + raw, + context.usize_field("num_vertices").or(context.num_vertices), + )?; + Ok(serde_json::to_value(graph)?) +} + +fn parse_grid_subgraph_value(raw: &str, kings: bool) -> Result { + let positions = util::parse_positions::(raw, "0,0")?; + if kings { + Ok(serde_json::to_value(KingsSubgraph::new(positions))?) + } else { + Ok(serde_json::to_value(TriangularSubgraph::new(positions))?) + } +} + +fn parse_unit_disk_graph_value(raw: &str, context: &CreateContext) -> Result { + let positions = util::parse_positions::(raw, "0.0,0.0")?; + let radius = context + .f64_field("radius") + .ok_or_else(|| anyhow::anyhow!("UnitDiskGraph parsing requires a prior radius field"))?; + Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) +} + fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { match type_name { "SimpleGraph" => "edge list: 0-1,1-2,2-3", @@ -8844,6 +9278,75 @@ mod tests { ); } + #[test] + fn test_parse_field_value_parses_simple_graph_to_json() { + let value = parse_field_value( + "SimpleGraph", + "graph", + "0-1,1-2", + &CreateContext::default(), + ) + .expect("parse graph"); + + assert_eq!( + value, + serde_json::json!({ + "num_vertices": 3, + "edges": [[0, 1], [1, 2]], + }) + ); + } + + #[test] + fn test_parse_field_value_parses_dependency_pairs() { + let value = parse_field_value( + "Vec<(Vec, Vec)>", + "dependencies", + "0,1>2,3;2>4", + &CreateContext::default(), + ) + .expect("parse dependencies"); + + assert_eq!( + value, + serde_json::json!([ + [[0, 1], [2, 3]], + [[2], [4]], + ]) + ); + } + + #[test] + fn test_parse_field_value_parses_job_shop_jobs() { + let value = parse_field_value( + "Vec>", + "jobs", + "0:3,1:4;1:2,0:3,1:2", + &CreateContext::default(), + ) + .expect("parse jobs"); + + assert_eq!( + value, + serde_json::json!([ + [[0, 3], [1, 4]], + [[1, 2], [0, 3], [1, 2]], + ]) + ); + } + + #[test] + fn test_parse_field_value_parses_quantifiers_using_context_num_vars() { + let context = CreateContext::default().with_field("num_vars", serde_json::json!(3)); + let value = parse_field_value("Vec", "quantifiers", "E,A,E", &context) + .expect("parse quantifiers"); + + assert_eq!( + value, + serde_json::json!(["Exists", "ForAll", "Exists"]) + ); + } + #[test] fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") From 7fee7280d9b8dfe2d1ce14357e083d2779ad847e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 12:38:32 +0800 Subject: [PATCH 06/13] Phase 3: add schema-driven create builder --- problemreductions-cli/src/commands/create.rs | 286 +++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 23fcaba0..856f6cca 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -625,6 +625,193 @@ impl CreateContext { .get(name) .and_then(serde_json::Value::as_f64) } + + fn remember(&mut self, name: &str, concrete_type: &str, value: &serde_json::Value) { + self.parsed_fields.insert(name.to_string(), value.clone()); + + match normalize_type_name(concrete_type).as_str() { + "SimpleGraph" => { + self.num_vertices = value + .get("num_vertices") + .and_then(serde_json::Value::as_u64) + .and_then(|raw| usize::try_from(raw).ok()); + self.num_edges = value + .get("edges") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "DirectedGraph" => { + self.num_vertices = value + .get("num_vertices") + .and_then(serde_json::Value::as_u64) + .and_then(|raw| usize::try_from(raw).ok()); + self.num_arcs = value + .get("arcs") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "KingsSubgraph" | "TriangularSubgraph" => { + self.num_vertices = value + .get("positions") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "UnitDiskGraph" => { + self.num_vertices = value + .get("positions") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + self.num_edges = value + .get("edges") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + _ => {} + } + } +} + +fn create_schema_driven( + args: &CreateArgs, + canonical: &str, + resolved_variant: &BTreeMap, +) -> Result)>> { + let Some(schema) = collect_schemas().into_iter().find(|schema| schema.name == canonical) else { + return Ok(None); + }; + let Some(variant_entry) = + problemreductions::registry::find_variant_entry(canonical, resolved_variant) + else { + return Ok(None); + }; + + let graph_type = resolved_graph_type(resolved_variant); + let is_geometry = matches!( + graph_type, + "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" + ); + let flag_map = args.flag_map(); + let mut context = CreateContext::default(); + let mut json_map = serde_json::Map::new(); + + for field in &schema.fields { + let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); + let flag_keys = + schema_field_flag_keys(canonical, &field.name, &field.type_name, is_geometry); + let value = if let Some(raw_value) = get_schema_flag_value(&flag_map, &flag_keys) { + match parse_field_value(&concrete_type, &field.name, &raw_value, &context) { + Ok(value) => value, + Err(error) if is_unsupported_schema_parser(&error) => return Ok(None), + Err(error) => return Err(error), + } + } else if let Some(derived) = + derive_schema_field_value(canonical, &field.name, &concrete_type, &context)? + { + derived + } else { + return Ok(None); + }; + + context.remember(&field.name, &concrete_type, &value); + json_map.insert(field.name.clone(), value); + } + + let data = serde_json::Value::Object(json_map); + if (variant_entry.factory)(data.clone()).is_err() { + return Ok(None); + } + + Ok(Some((data, resolved_variant.clone()))) +} + +fn schema_field_flag_keys( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> Vec { + let mut keys = vec![field_name.replace('_', "-")]; + let display_key = problem_help_flag_name(canonical, field_name, field_type, is_geometry); + let display_key = display_key + .split('/') + .next() + .unwrap_or(&display_key) + .trim_start_matches("--") + .to_string(); + if !keys.contains(&display_key) { + keys.push(display_key); + } + keys +} + +fn get_schema_flag_value( + flag_map: &std::collections::HashMap<&'static str, Option>, + keys: &[String], +) -> Option { + keys.iter() + .find_map(|key| flag_map.get(key.as_str()).cloned().flatten()) +} + +fn resolve_schema_field_type( + type_name: &str, + resolved_variant: &BTreeMap, +) -> String { + let normalized = normalize_type_name(type_name); + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .unwrap_or("SimpleGraph"); + let weight_type = resolved_variant + .get("weight") + .map(String::as_str) + .unwrap_or("One"); + + match normalized.as_str() { + "G" => graph_type.to_string(), + "W" => weight_type.to_string(), + "W::Sum" => weight_sum_type(weight_type).to_string(), + "Vec" => format!("Vec<{weight_type}>"), + "Vec>" => format!("Vec>"), + "Vec<(usize,usize,W)>" => format!("Vec<(usize,usize,{weight_type})>"), + "Vec>" => format!("Vec>"), + other => other.to_string(), + } +} + +fn weight_sum_type(weight_type: &str) -> &'static str { + match weight_type { + "One" | "i32" => "i32", + "f64" => "f64", + _ => "i32", + } +} + +fn derive_schema_field_value( + canonical: &str, + field_name: &str, + concrete_type: &str, + context: &CreateContext, +) -> Result> { + if canonical == "LengthBoundedDisjointPaths" + && field_name == "max_paths" + && normalize_type_name(concrete_type) == "usize" + { + let graph_value = context.parsed_fields.get("graph").cloned(); + let source = context.usize_field("source"); + let sink = context.usize_field("sink"); + if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { + let graph: SimpleGraph = + serde_json::from_value(graph_value).context("Failed to deserialize graph")?; + let max_paths = graph.neighbors(source).len().min(graph.neighbors(sink).len()); + return Ok(Some(serde_json::json!(max_paths))); + } + } + + Ok(None) +} + +fn is_unsupported_schema_parser(error: &anyhow::Error) -> bool { + error.to_string().contains("Unsupported schema parser") } fn parse_field_value( @@ -9347,6 +9534,105 @@ mod tests { ); } + #[test] + fn test_create_schema_driven_builds_job_shop_scheduling() { + let cli = Cli::parse_from([ + "pred", + "create", + "JobShopScheduling", + "--jobs", + "0:3,1:4;1:2,0:3,1:2", + "--num-processors", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven(&args, "JobShopScheduling", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support JobShopScheduling"); + + let entry = problemreductions::registry::find_variant_entry("JobShopScheduling", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_processors"], 2); + assert_eq!(data["jobs"][0], serde_json::json!([[0, 3], [1, 4]])); + } + + #[test] + fn test_create_schema_driven_builds_quantified_boolean_formulas() { + let cli = Cli::parse_from([ + "pred", + "create", + "QuantifiedBooleanFormulas", + "--num-vars", + "3", + "--quantifiers", + "E,A,E", + "--clauses", + "1,2;-1,3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "QuantifiedBooleanFormulas", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support QBF"); + + let entry = problemreductions::registry::find_variant_entry( + "QuantifiedBooleanFormulas", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["quantifiers"], serde_json::json!(["Exists", "ForAll", "Exists"])); + } + + #[test] + fn test_create_schema_driven_builds_undirected_flow_lower_bounds() { + let cli = Cli::parse_from([ + "pred", + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3", + "--capacities", + "2,2,2,2", + "--lower-bounds", + "1,0,0,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "UndirectedFlowLowerBounds", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support UndirectedFlowLowerBounds"); + + let entry = problemreductions::registry::find_variant_entry( + "UndirectedFlowLowerBounds", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["num_vertices"], 4); + assert_eq!(data["capacities"], serde_json::json!([2, 2, 2, 2])); + assert_eq!(data["lower_bounds"], serde_json::json!([1, 0, 0, 1])); + } + #[test] fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") From 398b33ca8ba76bb1ff10f9d53937f0d23e94535d Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 13:06:07 +0800 Subject: [PATCH 07/13] Phase 6: wire schema-driven create with fallback --- problemreductions-cli/src/commands/create.rs | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 856f6cca..9288cdf3 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -608,6 +608,7 @@ struct CreateContext { } impl CreateContext { + #[cfg(test)] fn with_field(mut self, name: &str, value: serde_json::Value) -> Self { self.parsed_fields.insert(name.to_string(), value); self @@ -676,6 +677,10 @@ fn create_schema_driven( canonical: &str, resolved_variant: &BTreeMap, ) -> Result)>> { + if !schema_driven_supported_problem(canonical) { + return Ok(None); + } + let Some(schema) = collect_schemas().into_iter().find(|schema| schema.name == canonical) else { return Ok(None); }; @@ -724,6 +729,13 @@ fn create_schema_driven( Ok(Some((data, resolved_variant.clone()))) } +fn schema_driven_supported_problem(canonical: &str) -> bool { + matches!( + canonical, + "JobShopScheduling" | "QuantifiedBooleanFormulas" | "UndirectedFlowLowerBounds" + ) +} + fn schema_field_flag_keys( canonical: &str, field_name: &str, @@ -1999,6 +2011,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { std::process::exit(2); } + if let Some((data, variant)) = create_schema_driven(args, canonical, &resolved_variant)? { + let output = ProblemJsonOutput { + problem_type: canonical.to_string(), + variant, + data, + }; + return emit_problem_output(&output, out); + } + let (data, variant) = match canonical { // Graph problems with vertex weights "MaximumIndependentSet" @@ -9633,6 +9654,47 @@ mod tests { assert_eq!(data["lower_bounds"], serde_json::json!([1, 0, 0, 1])); } + #[test] + fn test_create_falls_back_when_schema_path_is_unsupported() { + let cli = Cli::parse_from([ + "pred", + "create", + "ConjunctiveBooleanQuery", + "--domain-size", + "6", + "--relations", + "2:0,3|1,3;3:0,1,5|1,2,5", + "--conjuncts-spec", + "0:v0,c3;0:v1,c3;1:v0,v1,c5", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert!( + create_schema_driven(&args, "ConjunctiveBooleanQuery", &BTreeMap::new()) + .expect("schema-driven path should not hard fail") + .is_none(), + "unsupported CBQ schema fields should fall back to the legacy path" + ); + + let out = OutputConfig { + output: Some(temp_output_path("schema_fallback_cbq")), + quiet: true, + json: false, + auto_json: false, + }; + create(&args, &out).expect("legacy fallback should still construct the problem"); + + let created: ProblemJsonOutput = + serde_json::from_str(&fs::read_to_string(out.output.as_ref().unwrap()).unwrap()) + .unwrap(); + fs::remove_file(out.output.as_ref().unwrap()).ok(); + + assert_eq!(created.problem_type, "ConjunctiveBooleanQuery"); + } + #[test] fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") From 539cb13a51723b4405372cde22e77ec2fc13c464 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 13:19:45 +0800 Subject: [PATCH 08/13] Phase 5: generate create help from schema examples --- problemreductions-cli/src/cli.rs | 5 +- problemreductions-cli/src/commands/create.rs | 525 +++++++++++++++---- problemreductions-cli/tests/cli_tests.rs | 6 +- 3 files changed, 442 insertions(+), 94 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index cfe0043d..73e8bbb5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1070,7 +1070,10 @@ impl CreateArgs { "dependencies", self.dependencies.as_deref().or(self.deps.as_deref()) ); - insert!("deps", self.dependencies.as_deref().or(self.deps.as_deref())); + insert!( + "deps", + self.dependencies.as_deref().or(self.deps.as_deref()) + ); insert!("relation-attrs", self.relation_attrs.as_deref()); insert!("known-keys", self.known_keys.as_deref()); insert!("num-objects", self.num_objects); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 9288cdf3..16ca0fae 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -681,7 +681,10 @@ fn create_schema_driven( return Ok(None); } - let Some(schema) = collect_schemas().into_iter().find(|schema| schema.name == canonical) else { + let Some(schema) = collect_schemas() + .into_iter() + .find(|schema| schema.name == canonical) + else { return Ok(None); }; let Some(variant_entry) = @@ -814,7 +817,10 @@ fn derive_schema_field_value( if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { let graph: SimpleGraph = serde_json::from_value(graph_value).context("Failed to deserialize graph")?; - let max_paths = graph.neighbors(source).len().min(graph.neighbors(sink).len()); + let max_paths = graph + .neighbors(source) + .len() + .min(graph.neighbors(sink).len()); return Ok(Some(serde_json::json!(max_paths))); } } @@ -882,9 +888,7 @@ fn parse_field_value( "i32" => parse_scalar_value::(raw)?, "i64" => parse_scalar_value::(raw)?, "f64" => parse_scalar_value::(raw)?, - other => bail!( - "Unsupported schema parser for field '{field_name}' with type '{other}'" - ), + other => bail!("Unsupported schema parser for field '{field_name}' with type '{other}'"), }; Ok(value) @@ -1032,9 +1036,9 @@ where .filter(|entry| !entry.trim().is_empty()) .map(|entry| { let entry = entry.trim(); - let (edge_part, weight_part) = entry - .split_once(':') - .ok_or_else(|| anyhow::anyhow!("Invalid weighted edge '{entry}': expected u-v:w"))?; + let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid weighted edge '{entry}': expected u-v:w") + })?; let (u_str, v_str) = if let Some((u, v)) = edge_part.split_once('-') { (u, v) } else if let Some((u, v)) = edge_part.split_once('>') { @@ -1059,23 +1063,22 @@ where T: std::str::FromStr + Serialize, T::Err: std::fmt::Display, { - let pairs: Vec<(usize, T)> = raw - .split(',') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let entry = entry.trim(); - let (index, value) = entry - .split_once(':') - .ok_or_else(|| anyhow::anyhow!("Invalid pair '{entry}': expected index:value"))?; - Ok(( - index.trim().parse::()?, - value - .trim() - .parse::() - .map_err(|err| anyhow::anyhow!("Invalid value '{}': {err}", value.trim()))?, - )) - }) - .collect::>()?; + let pairs: Vec<(usize, T)> = + raw.split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (index, value) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid pair '{entry}': expected index:value") + })?; + Ok(( + index.trim().parse::()?, + value.trim().parse::().map_err(|err| { + anyhow::anyhow!("Invalid value '{}': {err}", value.trim()) + })?, + )) + }) + .collect::>()?; Ok(serde_json::to_value(pairs)?) } @@ -1582,6 +1585,13 @@ fn uses_edge_weights_flag(canonical: &str) -> bool { ) } +fn uses_edge_weights_flag_for_edge_lengths(canonical: &str) -> bool { + matches!( + canonical, + "LongestCircuit" | "MinMaxMulticenter" | "MinimumSumMulticenter" + ) +} + fn help_flag_name(canonical: &str, field_name: &str) -> String { // Problem-specific overrides first match (canonical, field_name) { @@ -1595,6 +1605,8 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { } ("JobShopScheduling", "jobs") => return "jobs".to_string(), ("LengthBoundedDisjointPaths", "max_length") => return "max-length".to_string(), + ("ConsecutiveBlockMinimization", "bound") => return "bound-k".to_string(), + ("GroupingBySwapping", "budget") => return "bound".to_string(), ("RectilinearPictureCompression", "bound") => return "bound".to_string(), ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), ("PrimeAttributeName", "dependencies") => return "dependencies".to_string(), @@ -1620,6 +1632,9 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { if field_name == "weights" && uses_edge_weights_flag(canonical) { return "edge-weights".to_string(); } + if field_name == "edge_lengths" && uses_edge_weights_flag_for_edge_lengths(canonical) { + return "edge-weights".to_string(); + } // General field-name overrides (previously in cli_flag_name) match field_name { "universe_size" => "universe-size".to_string(), @@ -1629,7 +1644,6 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { "edges" => "biedges".to_string(), "vertex_weights" => "weights".to_string(), "potential_weights" => "potential-weights".to_string(), - "edge_lengths" => "edge-weights".to_string(), "num_tasks" => "num-tasks".to_string(), "precedences" => "precedences".to_string(), "threshold" => "threshold".to_string(), @@ -1704,6 +1718,9 @@ fn help_flag_hint( ("PathConstrainedNetworkFlow", "paths") => { "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" } + ("ConsecutiveBlockMinimization", "matrix") => { + "JSON 2D bool array: '[[true,false,true],[false,true,true]]'" + } ("ConsecutiveOnesMatrixAugmentation", "matrix") => { "semicolon-separated 0/1 rows: \"1,0;0,1\"" } @@ -1795,7 +1812,11 @@ fn validate_sequencing_within_intervals_inputs( Ok(()) } -fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { +fn print_problem_help(canonical: &str, resolved_variant: &BTreeMap) -> Result<()> { + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .filter(|graph_type| *graph_type != "SimpleGraph"); let is_geometry = matches!( graph_type, Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") @@ -1857,8 +1878,11 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { bail!("{}", crate::problem_name::unknown_problem_error(canonical)); } - let example = example_for(canonical, graph_type); - if !example.is_empty() { + let example = schema_help_example_for(canonical, resolved_variant).or_else(|| { + let fallback = example_for(canonical, graph_type); + (!fallback.is_empty()).then(|| fallback.to_string()) + }); + if let Some(example) = example { eprintln!("\nExample:"); eprintln!( " pred create {} {}", @@ -1872,6 +1896,276 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { Ok(()) } +fn schema_help_example_for( + canonical: &str, + resolved_variant: &BTreeMap, +) -> Option { + let schema = collect_schemas() + .into_iter() + .find(|schema| schema.name == canonical)?; + let example = problemreductions::example_db::find_model_example(&ProblemRef { + name: canonical.to_string(), + variant: resolved_variant.clone(), + }) + .ok()?; + let instance = example.instance.as_object()?; + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .filter(|graph_type| *graph_type != "SimpleGraph"); + let is_geometry = matches!( + graph_type, + Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") + ); + + let mut args = Vec::new(); + for field in &schema.fields { + let value = instance.get(&field.name)?; + let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); + let flag_name = + schema_example_flag_name(canonical, &field.name, &field.type_name, is_geometry); + let rendered = + format_schema_help_example_value(canonical, &field.name, &concrete_type, value)?; + args.push(format!("--{flag_name} {}", quote_cli_arg(&rendered))); + } + Some(args.join(" ")) +} + +fn schema_example_flag_name( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> String { + problem_help_flag_name(canonical, field_name, field_type, is_geometry) + .split('/') + .next() + .unwrap_or(field_name) + .trim_start_matches("--") + .to_string() +} + +fn quote_cli_arg(raw: &str) -> String { + if raw.is_empty() + || raw.chars().any(|ch| { + ch.is_whitespace() + || matches!( + ch, + ';' | '>' | '|' | '[' | ']' | '{' | '}' | '(' | ')' | '"' | '\'' + ) + }) + { + format!("\"{}\"", raw.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + raw.to_string() + } +} + +fn format_schema_help_example_value( + canonical: &str, + field_name: &str, + concrete_type: &str, + value: &serde_json::Value, +) -> Option { + match (canonical, field_name) { + ("ConsecutiveBlockMinimization", "matrix") + | ("FeasibleBasisExtension", "matrix") + | ("MinimumWeightDecoding", "matrix") + | ("MinimumWeightSolutionToLinearEquations", "matrix") => { + return serde_json::to_string(value).ok(); + } + _ => {} + } + match normalize_type_name(concrete_type).as_str() { + "SimpleGraph" => format_simple_graph_example(value), + "DirectedGraph" => format_directed_graph_example(value), + "Vec" => format_cnf_clause_list_example(value), + "Vec" => format_quantifier_list_example(value), + "Vec>" => format_job_shop_example(value), + "Vec<(Vec,Vec)>" => format_dependency_example(value), + "Vec" | "Vec" | "Vec" | "Vec" | "Vec" | "Vec" => { + format_scalar_array_example(value) + } + "Vec" => format_bool_array_example(value), + "Vec>" | "Vec>" | "Vec>" | "Vec>" + | "Vec>" => format_nested_numeric_rows(value), + "Vec>" => format_bool_matrix_example(value), + "Vec" => Some( + value + .as_array()? + .iter() + .map(|entry| entry.as_str().map(str::to_string)) + .collect::>>()? + .join(";"), + ), + "usize" | "u64" | "i32" | "i64" | "f64" | "BigUint" => format_scalar_example(value), + _ => None, + } +} + +fn format_scalar_example(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::String(string) => Some(string.clone()), + serde_json::Value::Bool(boolean) => Some(boolean.to_string()), + _ => None, + } +} + +fn format_scalar_array_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(format_scalar_example) + .collect::>>()? + .join(","), + ) +} + +fn format_bool_array_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|entry| { + entry + .as_bool() + .map(|boolean| if boolean { "1" } else { "0" }.to_string()) + }) + .collect::>>()? + .join(","), + ) +} + +fn format_nested_numeric_rows(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|row| format_scalar_array_example(row)) + .collect::>>()? + .join(";"), + ) +} + +fn format_cnf_clause_list_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|clause| format_scalar_array_example(clause.get("literals")?)) + .collect::>>()? + .join(";"), + ) +} + +fn format_bool_matrix_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(format_bool_array_example) + .collect::>>()? + .join(";"), + ) +} + +fn format_simple_graph_example(value: &serde_json::Value) -> Option { + Some( + value + .get("edges")? + .as_array()? + .iter() + .map(|edge| { + let pair = edge.as_array()?; + Some(format!( + "{}-{}", + pair.first()?.as_u64()?, + pair.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) +} + +fn format_directed_graph_example(value: &serde_json::Value) -> Option { + Some( + value + .get("arcs")? + .as_array()? + .iter() + .map(|arc| { + let pair = arc.as_array()?; + Some(format!( + "{}>{}", + pair.first()?.as_u64()?, + pair.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) +} + +fn format_quantifier_list_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|entry| match entry.as_str()? { + "Exists" => Some("E".to_string()), + "ForAll" => Some("A".to_string()), + _ => None, + }) + .collect::>>()? + .join(","), + ) +} + +fn format_job_shop_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|job| { + Some( + job.as_array()? + .iter() + .map(|task| { + let task = task.as_array()?; + Some(format!( + "{}:{}", + task.first()?.as_u64()?, + task.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) + }) + .collect::>>()? + .join(";"), + ) +} + +fn format_dependency_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|dependency| { + let dependency = dependency.as_array()?; + let lhs = format_scalar_array_example(dependency.first()?)?; + let rhs = format_scalar_array_example(dependency.get(1)?)?; + Some(format!("{lhs}>{rhs}")) + }) + .collect::>>()? + .join(";"), + ) +} + fn problem_help_flag_name( canonical: &str, field_name: &str, @@ -1937,7 +2231,10 @@ fn validate_length_bounded_disjoint_paths_args( )); } if max_length == 0 { - return Err(lbdp_validation_error("--max-length must be positive", usage)); + return Err(lbdp_validation_error( + "--max-length must be positive", + usage, + )); } Ok(max_length) } @@ -2002,12 +2299,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // Show schema-driven help when no data flags are provided if all_data_flags_empty(args) { - let gt = if graph_type != "SimpleGraph" { - Some(graph_type) - } else { - None - }; - print_problem_help(canonical, gt)?; + print_problem_help(canonical, &resolved_variant)?; std::process::exit(2); } @@ -4505,7 +4797,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let matrix = parse_bool_matrix(args)?; let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}") + anyhow::anyhow!( + "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" + ) })?; let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; if bound == 0 { @@ -5084,11 +5378,15 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // PreemptiveScheduling "PreemptiveScheduling" => { let usage = "Usage: pred create PreemptiveScheduling --lengths 2,1,3,2,1 --num-processors 2 [--precedences \"0>2,1>3\"]"; - let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "PreemptiveScheduling requires --lengths and --num-processors\n\n{usage}" - ) - })?; + let lengths_str = args + .lengths + .as_deref() + .or(args.sizes.as_deref()) + .ok_or_else(|| { + anyhow::anyhow!( + "PreemptiveScheduling requires --lengths and --num-processors\n\n{usage}" + ) + })?; let num_processors = args.num_processors.ok_or_else(|| { anyhow::anyhow!("PreemptiveScheduling requires --num-processors\n\n{usage}") })?; @@ -5102,8 +5400,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "PreemptiveScheduling: all task lengths must be positive\n\n{usage}" ); let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = - match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { + let precedences: Vec<(usize, usize)> = match args + .precedences + .as_deref() + .or(args.precedence_pairs.as_deref()) + { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -5275,8 +5576,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = - parse_precedence_pairs(args.precedences.as_deref().or(args.precedence_pairs.as_deref()))?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; if let Some(lengths_str) = args.lengths.as_deref().or(args.sizes.as_deref()) { // Arbitrary-length variant (W = i32) @@ -5347,8 +5651,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) })?; let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences: Vec<(usize, usize)> = - match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { + let precedences: Vec<(usize, usize)> = match args + .precedences + .as_deref() + .or(args.precedence_pairs.as_deref()) + { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -5534,8 +5841,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "task lengths must be positive" ); let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = - match args.precedences.as_deref().or(args.precedence_pairs.as_deref()) { + let precedences: Vec<(usize, usize)> = match args + .precedences + .as_deref() + .or(args.precedence_pairs.as_deref()) + { Some(s) if !s.is_empty() => s .split(',') .map(|pair| { @@ -5637,8 +5947,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) })?; let costs: Vec = util::parse_comma_list(costs_str)?; - let precedences = - parse_precedence_pairs(args.precedences.as_deref().or(args.precedence_pairs.as_deref()))?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; validate_precedence_pairs(&precedences, costs.len())?; ( ser(SequencingToMinimizeMaximumCumulativeCost::new( @@ -5760,9 +6073,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // JobShopScheduling "JobShopScheduling" => { let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let job_tasks = args.job_tasks.as_deref().ok_or_else(|| { - anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}") - })?; + let job_tasks = args + .job_tasks + .as_deref() + .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; let jobs = parse_job_shop_jobs(job_tasks)?; let inferred_processors = jobs .iter() @@ -8278,9 +8592,9 @@ fn parse_bundles(args: &CreateArgs, num_arcs: usize, usage: &str) -> Result Result { - let raw_bound = args - .bound - .ok_or_else(|| anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}"))?; + let raw_bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}") + })?; anyhow::ensure!( raw_bound >= 0, "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" @@ -9488,13 +9802,8 @@ mod tests { #[test] fn test_parse_field_value_parses_simple_graph_to_json() { - let value = parse_field_value( - "SimpleGraph", - "graph", - "0-1,1-2", - &CreateContext::default(), - ) - .expect("parse graph"); + let value = parse_field_value("SimpleGraph", "graph", "0-1,1-2", &CreateContext::default()) + .expect("parse graph"); assert_eq!( value, @@ -9515,13 +9824,7 @@ mod tests { ) .expect("parse dependencies"); - assert_eq!( - value, - serde_json::json!([ - [[0, 1], [2, 3]], - [[2], [4]], - ]) - ); + assert_eq!(value, serde_json::json!([[[0, 1], [2, 3]], [[2], [4]],])); } #[test] @@ -9536,10 +9839,7 @@ mod tests { assert_eq!( value, - serde_json::json!([ - [[0, 3], [1, 4]], - [[1, 2], [0, 3], [1, 2]], - ]) + serde_json::json!([[[0, 3], [1, 4]], [[1, 2], [0, 3], [1, 2]],]) ); } @@ -9549,10 +9849,7 @@ mod tests { let value = parse_field_value("Vec", "quantifiers", "E,A,E", &context) .expect("parse quantifiers"); - assert_eq!( - value, - serde_json::json!(["Exists", "ForAll", "Exists"]) - ); + assert_eq!(value, serde_json::json!(["Exists", "ForAll", "Exists"])); } #[test] @@ -9605,13 +9902,14 @@ mod tests { .expect("schema-driven create should parse") .expect("schema-driven path should support QBF"); - let entry = problemreductions::registry::find_variant_entry( - "QuantifiedBooleanFormulas", - &variant, - ) - .expect("variant entry"); + let entry = + problemreductions::registry::find_variant_entry("QuantifiedBooleanFormulas", &variant) + .expect("variant entry"); (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["quantifiers"], serde_json::json!(["Exists", "ForAll", "Exists"])); + assert_eq!( + data["quantifiers"], + serde_json::json!(["Exists", "ForAll", "Exists"]) + ); } #[test] @@ -9643,11 +9941,9 @@ mod tests { .expect("schema-driven create should parse") .expect("schema-driven path should support UndirectedFlowLowerBounds"); - let entry = problemreductions::registry::find_variant_entry( - "UndirectedFlowLowerBounds", - &variant, - ) - .expect("variant entry"); + let entry = + problemreductions::registry::find_variant_entry("UndirectedFlowLowerBounds", &variant) + .expect("variant entry"); (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); assert_eq!(data["graph"]["num_vertices"], 4); assert_eq!(data["capacities"], serde_json::json!([2, 2, 2, 2])); @@ -9695,6 +9991,53 @@ mod tests { assert_eq!(created.problem_type, "ConjunctiveBooleanQuery"); } + #[test] + fn test_schema_help_example_for_qbf_uses_example_db() { + let example = + schema_help_example_for("QuantifiedBooleanFormulas", &BTreeMap::new()).unwrap(); + assert_eq!( + example, + "--num-vars 2 --quantifiers E,A --clauses \"1,2;1,-2\"" + ); + } + + #[test] + fn test_schema_help_example_for_cbm_uses_json_matrix_syntax() { + let example = + schema_help_example_for("ConsecutiveBlockMinimization", &BTreeMap::new()).unwrap(); + assert!(example.contains("--matrix \"[[false,true,false,false,false,false],[true,false,true,false,false,false],[false,true,false,true,false,false],[false,false,true,false,true,false],[false,false,false,true,false,true],[false,false,false,false,true,false]]\"")); + assert!(example.contains("--bound-k 6")); + } + + #[test] + fn test_problem_help_flag_name_uses_bound_for_grouping_by_swapping_budget() { + assert_eq!( + problem_help_flag_name("GroupingBySwapping", "budget", "usize", false), + "bound" + ); + } + + #[test] + fn test_problem_help_flag_name_preserves_edge_lengths_for_shortest_weight_constrained_path() { + assert_eq!( + problem_help_flag_name( + "ShortestWeightConstrainedPath", + "edge_lengths", + "Vec", + false + ), + "edge-lengths" + ); + } + + #[test] + fn test_problem_help_flag_name_uses_edge_weights_for_longest_circuit_edge_lengths() { + assert_eq!( + problem_help_flag_name("LongestCircuit", "edge_lengths", "Vec", false), + "edge-weights" + ); + } + #[test] fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index e0320130..9c0eccae 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -4567,7 +4567,8 @@ fn test_create_length_bounded_disjoint_paths_rejects_negative_bound_value() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), + stderr + .contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), "expected user-facing negative-bound error, got: {stderr}" ); } @@ -4590,7 +4591,8 @@ fn test_create_random_length_bounded_disjoint_paths_rejects_negative_bound_value assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), + stderr + .contains("--max-length must be a nonnegative integer for LengthBoundedDisjointPaths"), "expected shared negative-bound validation, got: {stderr}" ); } From acffd597f221aabb740541beca1a52b393bbd539 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 16:36:19 +0800 Subject: [PATCH 09/13] refactor: expand schema-driven create coverage --- problemreductions-cli/src/commands/create.rs | 1656 +++++++++++++++++- 1 file changed, 1603 insertions(+), 53 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 16ca0fae..26f80eed 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -614,6 +614,17 @@ impl CreateContext { self } + fn seed_field(&mut self, name: &str, value: T) -> Result<()> { + let value = serde_json::to_value(value)?; + if name == "num_vertices" { + self.num_vertices = value + .as_u64() + .and_then(|raw| usize::try_from(raw).ok()); + } + self.parsed_fields.insert(name.to_string(), value); + Ok(()) + } + fn usize_field(&self, name: &str) -> Option { self.parsed_fields .get(name) @@ -700,22 +711,60 @@ fn create_schema_driven( ); let flag_map = args.flag_map(); let mut context = CreateContext::default(); + seed_schema_context_from_cli(args, graph_type, &mut context)?; let mut json_map = serde_json::Map::new(); for field in &schema.fields { let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); let flag_keys = schema_field_flag_keys(canonical, &field.name, &field.type_name, is_geometry); - let value = if let Some(raw_value) = get_schema_flag_value(&flag_map, &flag_keys) { - match parse_field_value(&concrete_type, &field.name, &raw_value, &context) { + let raw_value = get_schema_flag_value(&flag_map, &flag_keys); + let value = if !schema_field_requires_derived_input(&field.name, &concrete_type) { + if let Some(raw_value) = raw_value.clone() { + match parse_schema_field_value( + args, + canonical, + &concrete_type, + &field.name, + &raw_value, + &context, + ) { + Ok(value) => value, + Err(error) if is_unsupported_schema_parser(&error) => return Ok(None), + Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), + } + } else if let Some(derived) = derive_schema_field_value( + args, + canonical, + &field.name, + &concrete_type, + &context, + )? { + derived + } else { + return Ok(None); + } + } else if let Some(derived) = derive_schema_field_value( + args, + canonical, + &field.name, + &concrete_type, + &context, + )? { + derived + } else if let Some(raw_value) = raw_value { + match parse_schema_field_value( + args, + canonical, + &concrete_type, + &field.name, + &raw_value, + &context, + ) { Ok(value) => value, Err(error) if is_unsupported_schema_parser(&error) => return Ok(None), - Err(error) => return Err(error), + Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), } - } else if let Some(derived) = - derive_schema_field_value(canonical, &field.name, &concrete_type, &context)? - { - derived } else { return Ok(None); }; @@ -725,6 +774,8 @@ fn create_schema_driven( } let data = serde_json::Value::Object(json_map); + validate_schema_driven_semantics(args, canonical, resolved_variant, &data) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; if (variant_entry.factory)(data.clone()).is_err() { return Ok(None); } @@ -732,11 +783,146 @@ fn create_schema_driven( Ok(Some((data, resolved_variant.clone()))) } +fn parse_schema_field_value( + args: &CreateArgs, + canonical: &str, + concrete_type: &str, + field_name: &str, + raw: &str, + context: &CreateContext, +) -> Result { + match (canonical, field_name) { + ("BoundedComponentSpanningForest", "max_weight") => { + 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 --max-weight 6"; + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") + })?; + let max_weight = i32::try_from(bound_raw).map_err(|_| { + anyhow::anyhow!( + "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" + ) + })?; + Ok(serde_json::json!(max_weight)) + } + ("ConsecutiveBlockMinimization", "matrix") => { + let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("FeasibleBasisExtension", "matrix") => { + let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("IntegralFlowBundles", "bundle_capacities") => { + let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; + let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let bundles = parse_bundles(args, num_arcs, usage)?; + Ok(serde_json::to_value(parse_bundle_capacities( + args, + bundles.len(), + usage, + )?)?) + } + ("IntegralFlowHomologousArcs", "homologous_pairs") => { + Ok(serde_json::to_value(parse_homologous_pairs(args)?)?) + } + ("LengthBoundedDisjointPaths", "max_length") => { + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") + })?; + let max_length = usize::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}" + ) + })?; + Ok(serde_json::json!(max_length)) + } + ("MinimumDecisionTree", "test_matrix") => { + let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumDecisionTree requires --test-matrix as a JSON 2D bool array\n\n{usage}\n\nFailed to parse --test-matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("MinimumWeightDecoding", "matrix") => { + let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("MinimumWeightSolutionToLinearEquations", "matrix") => { + let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("MultipleCopyFileAllocation", "usage") => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + Ok(serde_json::to_value(parse_vertex_i64_values( + args.usage.as_deref(), + "usage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?)?) + } + ("MultipleCopyFileAllocation", "storage") => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + Ok(serde_json::to_value(parse_vertex_i64_values( + args.storage.as_deref(), + "storage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?)?) + } + ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => { + Ok(serde_json::to_value(parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?)?) + } + ("UndirectedTwoCommodityIntegralFlow", "capacities") => { + let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + Ok(serde_json::to_value(parse_capacities( + args, + graph.num_edges(), + usage, + )?)?) + } + _ => parse_field_value(concrete_type, field_name, raw, context), + } +} + fn schema_driven_supported_problem(canonical: &str) -> bool { - matches!( - canonical, - "JobShopScheduling" | "QuantifiedBooleanFormulas" | "UndirectedFlowLowerBounds" - ) + canonical != "ILP" && canonical != "CircuitSAT" } fn schema_field_flag_keys( @@ -801,12 +987,81 @@ fn weight_sum_type(weight_type: &str) -> &'static str { } } +fn seed_schema_context_from_cli( + args: &CreateArgs, + graph_type: &str, + context: &mut CreateContext, +) -> Result<()> { + if let Some(num_vertices) = args.num_vertices { + context.seed_field("num_vertices", num_vertices)?; + } + if graph_type == "UnitDiskGraph" { + context.seed_field("radius", args.radius.unwrap_or(1.0))?; + } + Ok(()) +} + fn derive_schema_field_value( + args: &CreateArgs, canonical: &str, field_name: &str, concrete_type: &str, context: &CreateContext, ) -> Result> { + if let Some(defaulted) = + derive_schema_default_value(canonical, field_name, concrete_type, context)? + { + return Ok(Some(defaulted)); + } + + if field_name == "graph" && concrete_type == "MixedGraph" { + let usage = format!("Usage: pred create {canonical} {}", example_for(canonical, None)); + return Ok(Some(serde_json::to_value(parse_mixed_graph(args, &usage)?)?)); + } + + if field_name == "graph" && concrete_type == "BipartiteGraph" { + let left = args + .left + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; + let right = args + .right + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; + let edges_raw = args + .biedges + .as_deref() + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --biedges"))?; + let edges = util::parse_edge_pairs(edges_raw)?; + validate_bipartite_edges(canonical, left, right, &edges)?; + return Ok(Some(serde_json::to_value(BipartiteGraph::new( + left, right, edges, + ))?)); + } + + if canonical == "ClosestVectorProblem" + && field_name == "bounds" + && normalize_type_name(concrete_type) == "Vec" + { + return Ok(Some(parse_cvp_bounds_value(args.bounds.as_deref(), context)?)); + } + + if canonical == "ConjunctiveBooleanQuery" + && field_name == "num_variables" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args.conjuncts_spec.as_deref().ok_or_else(|| { + anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec") + })?; + return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); + } + + if canonical == "ConsistencyOfDatabaseFrequencyTables" + && field_name == "known_values" + && normalize_type_name(concrete_type) == "Vec" + && args.known_values.is_none() + { + return Ok(Some(serde_json::json!([]))); + } + if canonical == "LengthBoundedDisjointPaths" && field_name == "max_paths" && normalize_type_name(concrete_type) == "usize" @@ -828,10 +1083,60 @@ fn derive_schema_field_value( Ok(None) } +fn derive_schema_default_value( + canonical: &str, + field_name: &str, + concrete_type: &str, + context: &CreateContext, +) -> Result> { + let normalized = normalize_type_name(concrete_type); + + let one_list = |len: usize| match normalized.as_str() { + "Vec" | "Vec" => Some(serde_json::json!(vec![1_i32; len])), + "Vec" => Some(serde_json::json!(vec![1_u64; len])), + "Vec" => Some(serde_json::json!(vec![1_i64; len])), + "Vec" => Some(serde_json::json!(vec![1_usize; len])), + "Vec" => Some(serde_json::json!(vec![1.0_f64; len])), + _ => None, + }; + + let derived = match field_name { + "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), + "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), + "arc_weights" if context.num_arcs.is_some() => context.num_arcs.and_then(one_list), + "capacities" if canonical == "PathConstrainedNetworkFlow" => { + context.num_arcs.and_then(one_list) + } + _ => None, + }; + + Ok(derived) +} + +fn schema_field_requires_derived_input(field_name: &str, concrete_type: &str) -> bool { + field_name == "graph" && matches!(concrete_type, "MixedGraph" | "BipartiteGraph") +} + fn is_unsupported_schema_parser(error: &anyhow::Error) -> bool { error.to_string().contains("Unsupported schema parser") } +fn with_schema_usage( + error: anyhow::Error, + canonical: &str, + resolved_variant: &BTreeMap, +) -> anyhow::Error { + let message = error.to_string(); + if message.contains("Usage: pred create") { + return error; + } + let graph_type = resolved_variant.get("graph").map(String::as_str); + anyhow::anyhow!( + "{message}\n\nUsage: pred create {canonical} {}", + example_for(canonical, graph_type) + ) +} + fn parse_field_value( concrete_type: &str, field_name: &str, @@ -850,13 +1155,15 @@ fn parse_field_value( "Vec" => parse_numeric_list_value::(raw)?, "Vec" => parse_numeric_list_value::(raw)?, "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, "Vec" => parse_bool_list_value(raw)?, "Vec>" => parse_nested_numeric_list_value::(raw)?, "Vec>" => parse_nested_numeric_list_value::(raw)?, "Vec>" => parse_nested_numeric_list_value::(raw)?, "Vec>" => parse_nested_numeric_list_value::(raw)?, "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => serde_json::to_value(parse_bool_rows(raw)?)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_bool_rows_value(raw, field_name)?, "Vec>>" => parse_3d_numeric_list_value::(raw)?, "Vec>>" => parse_3d_numeric_list_value::(raw)?, "Vec<[usize;3]>" => parse_triple_array_list_value(raw)?, @@ -866,16 +1173,24 @@ fn parse_field_value( "Vec<(usize,f64)>" => parse_indexed_numeric_pairs_value::(raw)?, "Vec<(usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, "Vec<(usize,usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,usize,One)>" => parse_weighted_edge_list_value::(raw)?, "Vec<(usize,usize,i32)>" => parse_weighted_edge_list_value::(raw)?, "Vec<(usize,usize,i64)>" => parse_weighted_edge_list_value::(raw)?, "Vec<(usize,usize,u64)>" => parse_weighted_edge_list_value::(raw)?, "Vec<(usize,usize,f64)>" => parse_weighted_edge_list_value::(raw)?, "Vec<(Vec,Vec)>" => serde_json::to_value(parse_dependencies(raw)?)?, "Vec<(Vec,usize)>" => serde_json::to_value(parse_implications(raw)?)?, + "Vec<(usize,Vec)>" => serde_json::to_value(parse_cbq_conjuncts(raw, context)?)?, "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, + "Vec" => { + serde_json::to_value(parse_cdft_frequency_tables_value(raw, context)?)? + } + "Vec" => serde_json::to_value(parse_cdft_known_values_value(raw, context)?)?, + "Vec" => serde_json::to_value(parse_cbq_relations(raw, context)?)?, "Vec" => parse_string_list_value(raw)?, + "Vec" => parse_cvp_bounds_value(Some(raw), context)?, "Vec" => parse_biguint_list_value(raw)?, "BigUint" => parse_biguint_value(raw)?, "Vec>" => parse_optional_bool_list_value(raw)?, @@ -924,6 +1239,13 @@ fn parse_bool_list_value(raw: &str) -> Result { Ok(serde_json::to_value(values)?) } +fn parse_bool_rows_value(raw: &str, field_name: &str) -> Result { + let flag = format!("--{}", field_name.replace('_', "-")); + let rows = parse_bool_rows(raw) + .map_err(|err| anyhow::anyhow!("{}", err.to_string().replace("--matrix", &flag)))?; + Ok(serde_json::to_value(rows)?) +} + fn parse_nested_numeric_list_value(raw: &str) -> Result where T: std::str::FromStr + Serialize, @@ -1005,6 +1327,144 @@ fn parse_pair_list_value(raw: &str) -> Result { Ok(serde_json::to_value(pairs)?) } +fn infer_cbq_num_variables(raw: &str) -> Result { + let mut num_vars = 0usize; + for conjunct in raw.split(';').filter(|entry| !entry.trim().is_empty()) { + let (_, args_str) = conjunct.trim().split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid conjunct format: expected 'rel_idx:args', got '{}'", + conjunct.trim() + ) + })?; + for arg in args_str.split(',').map(str::trim).filter(|arg| !arg.is_empty()) { + if let Some(rest) = arg.strip_prefix('v') { + let index: usize = rest.parse().map_err(|err| { + anyhow::anyhow!("Invalid variable index '{rest}': {err}") + })?; + num_vars = num_vars.max(index + 1); + } + } + } + Ok(num_vars) +} + +fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result> { + let domain_size = context + .usize_field("domain_size") + .ok_or_else(|| anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field"))?; + + raw.split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|rel_str| { + let rel_str = rel_str.trim(); + let (arity_str, tuples_str) = rel_str.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid relation format: expected 'arity:tuples', got '{rel_str}'") + })?; + let arity: usize = arity_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid arity '{arity_str}': {e}"))?; + let tuples: Vec> = if tuples_str.trim().is_empty() { + Vec::new() + } else { + tuples_str + .split('|') + .filter(|tuple| !tuple.trim().is_empty()) + .map(|tuple| { + let tuple: Vec = util::parse_comma_list(tuple.trim())?; + anyhow::ensure!( + tuple.len() == arity, + "Relation tuple has {} entries, expected arity {arity}", + tuple.len() + ); + for &value in &tuple { + anyhow::ensure!( + value < domain_size, + "Tuple value {value} >= domain-size {domain_size}" + ); + } + Ok(tuple) + }) + .collect::>()? + }; + Ok(CbqRelation { arity, tuples }) + }) + .collect() +} + +fn parse_cbq_conjuncts(raw: &str, context: &CreateContext) -> Result)>> { + let relations: Vec = serde_json::from_value( + context + .parsed_fields + .get("relations") + .cloned() + .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior relations field"))?, + ) + .context("Failed to deserialize parsed CBQ relations")?; + let domain_size = context + .usize_field("domain_size") + .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior domain_size field"))?; + let num_variables = context.usize_field("num_variables").unwrap_or(0); + + raw.split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|conj_str| { + let conj_str = conj_str.trim(); + let (idx_str, args_str) = conj_str.split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid conjunct format: expected 'rel_idx:args', got '{conj_str}'" + ) + })?; + let rel_idx: usize = idx_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid relation index '{idx_str}': {e}"))?; + anyhow::ensure!( + rel_idx < relations.len(), + "Conjunct references relation {rel_idx}, but only {} relations exist", + relations.len() + ); + + let query_args: Vec = args_str + .split(',') + .map(|arg| { + let arg = arg.trim(); + if let Some(rest) = arg.strip_prefix('v') { + let variable: usize = rest.parse().map_err(|e| { + anyhow::anyhow!("Invalid variable index '{rest}': {e}") + })?; + anyhow::ensure!( + variable < num_variables, + "Variable({variable}) >= num_variables ({num_variables})" + ); + Ok(QueryArg::Variable(variable)) + } else if let Some(rest) = arg.strip_prefix('c') { + let constant: usize = rest.parse().map_err(|e| { + anyhow::anyhow!("Invalid constant value '{rest}': {e}") + })?; + anyhow::ensure!( + constant < domain_size, + "Constant {constant} >= domain-size {domain_size}" + ); + Ok(QueryArg::Constant(constant)) + } else { + Err(anyhow::anyhow!( + "Invalid query arg '{arg}': expected vN (variable) or cN (constant)" + )) + } + }) + .collect::>()?; + anyhow::ensure!( + query_args.len() == relations[rel_idx].arity, + "Conjunct has {} args, but relation {rel_idx} has arity {}", + query_args.len(), + relations[rel_idx].arity + ); + Ok((rel_idx, query_args)) + }) + .collect() +} + fn parse_semicolon_tuple_list_value(raw: &str) -> Result where T: std::str::FromStr + Serialize, @@ -1113,6 +1573,67 @@ fn parse_string_list_value(raw: &str) -> Result { Ok(serde_json::to_value(values)?) } +fn parse_cdft_frequency_tables_value( + raw: &str, + context: &CreateContext, +) -> Result> { + let attribute_domains: Vec = serde_json::from_value( + context + .parsed_fields + .get("attribute_domains") + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "CDFT frequency table parsing requires prior attribute_domains field" + ) + })?, + ) + .context("Failed to deserialize parsed CDFT attribute domains")?; + let num_objects = context + .usize_field("num_objects") + .ok_or_else(|| anyhow::anyhow!("CDFT frequency table parsing requires prior num_objects field"))?; + parse_cdft_frequency_tables(raw, &attribute_domains, num_objects) +} + +fn parse_cdft_known_values_value(raw: &str, context: &CreateContext) -> Result> { + let attribute_domains: Vec = serde_json::from_value( + context + .parsed_fields + .get("attribute_domains") + .cloned() + .ok_or_else(|| anyhow::anyhow!("CDFT known-value parsing requires prior attribute_domains field"))?, + ) + .context("Failed to deserialize parsed CDFT attribute domains")?; + let num_objects = context + .usize_field("num_objects") + .ok_or_else(|| anyhow::anyhow!("CDFT known-value parsing requires prior num_objects field"))?; + parse_cdft_known_values(Some(raw), num_objects, &attribute_domains) +} + +fn parse_cvp_bounds_value(raw: Option<&str>, context: &CreateContext) -> Result { + let basis_len = context + .parsed_fields + .get("basis") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .ok_or_else(|| anyhow::anyhow!("CVP bounds parsing requires a prior basis field"))?; + + let (lower, upper) = match raw { + Some(raw) => { + let parts: Vec = util::parse_comma_list(raw)?; + anyhow::ensure!( + parts.len() == 2, + "--bounds expects \"lower,upper\" (e.g., \"-10,10\")" + ); + (parts[0], parts[1]) + } + None => (-10, 10), + }; + let bounds = + vec![problemreductions::models::algebraic::VarBounds::bounded(lower, upper); basis_len]; + Ok(serde_json::to_value(bounds)?) +} + fn parse_biguint_list_value(raw: &str) -> Result { let values: Vec = util::parse_biguint_list(raw)? .into_iter() @@ -1611,6 +2132,8 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), ("PrimeAttributeName", "dependencies") => return "dependencies".to_string(), ("PrimeAttributeName", "query_attribute") => return "query-attribute".to_string(), + ("ClosestVectorProblem", "target") => return "target-vec".to_string(), + ("ConjunctiveBooleanQuery", "conjuncts") => return "conjuncts-spec".to_string(), ("MixedChinesePostman", "arc_weights") => return "arc-weights".to_string(), ("ConsecutiveOnesMatrixAugmentation", "bound") => return "bound".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), @@ -1724,37 +2247,883 @@ fn help_flag_hint( ("ConsecutiveOnesMatrixAugmentation", "matrix") => { "semicolon-separated 0/1 rows: \"1,0;0,1\"" } - ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("MaximumLikelihoodRanking", "matrix") => { - "semicolon-separated i32 rows: \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"" + ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("MaximumLikelihoodRanking", "matrix") => { + "semicolon-separated i32 rows: \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"" + } + ("MinimumMatrixCover", "matrix") => "semicolon-separated i64 rows: \"0,3,1;3,0,2;1,2,0\"", + ("MinimumMatrixDomination", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("MinimumWeightDecoding", "matrix") => "JSON 2D bool array: '[[true,false],[false,true]]'", + ("MinimumWeightDecoding", "target") => "comma-separated booleans: \"true,true,false\"", + ("MinimumWeightSolutionToLinearEquations", "matrix") => { + "JSON 2D integer array: '[[1,2,3],[4,5,6]]'" + } + ("MinimumWeightSolutionToLinearEquations", "rhs") => "comma-separated integers: \"5,4\"", + ("FeasibleBasisExtension", "matrix") => "JSON 2D integer array: '[[1,0,1],[0,1,0]]'", + ("FeasibleBasisExtension", "rhs") => "comma-separated integers: \"7,5,3\"", + ("FeasibleBasisExtension", "required_columns") => "comma-separated column indices: \"0,1\"", + ("MinimumCodeGenerationParallelAssignments", "assignments") => { + "semicolon-separated target:reads entries: \"0:1,2;1:0;2:3;3:1,2\"" + } + ("NonTautology", "disjuncts") => "semicolon-separated disjuncts: \"1,2,3;-1,-2,-3\"", + ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { + "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + } + ("TimetableDesign", "requirements") => "semicolon-separated rows: \"1,0,1;0,1,0\"", + _ => type_format_hint(type_name, graph_type), + } +} + +fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> Result { + usize::try_from(bound) + .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) +} + +fn validate_prescribed_paths_against_graph( + graph: &DirectedGraph, + paths: &[Vec], + source: usize, + sink: usize, + usage: &str, +) -> Result<()> { + let arcs = graph.arcs(); + for path in paths { + anyhow::ensure!( + !path.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" + ); + let mut visited_vertices = BTreeSet::from([source]); + let mut current = source; + for &arc_index in path { + let &(tail, head) = arcs.get(arc_index).ok_or_else(|| { + anyhow::anyhow!( + "Path arc index {arc_index} out of bounds for {} arcs\n\n{usage}", + arcs.len() + ) + })?; + anyhow::ensure!( + tail == current, + "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}\n\n{usage}" + ); + anyhow::ensure!( + visited_vertices.insert(head), + "prescribed path repeats vertex {head}, so it is not a simple path\n\n{usage}" + ); + current = head; + } + anyhow::ensure!( + current == sink, + "prescribed path must end at sink {sink}, ended at {current}\n\n{usage}" + ); + } + Ok(()) +} + +fn validate_schema_driven_semantics( + args: &CreateArgs, + canonical: &str, + resolved_variant: &BTreeMap, + _data: &serde_json::Value, +) -> Result<()> { + match canonical { + "BalancedCompleteBipartiteSubgraph" => { + let usage = "pred create BalancedCompleteBipartiteSubgraph --left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3"; + let _ = parse_bipartite_problem_input( + args, + "BalancedCompleteBipartiteSubgraph", + "balanced biclique size", + usage, + )?; + } + "BiconnectivityAugmentation" => { + let usage = "Usage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let potential_edges = parse_potential_edges(args)?; + validate_potential_edges(&graph, &potential_edges)?; + let _ = parse_budget(args)?; + } + "BoundedComponentSpanningForest" => { + 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 --max-weight 6"; + let (_, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") + })?; + let weights = parse_vertex_weights(args, n)?; + if weights.iter().any(|&weight| weight < 0) { + bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}"); + } + let max_components = args.k.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}") + })?; + if max_components == 0 { + bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); + } + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") + })?; + if bound_raw <= 0 { + bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); + } + let _ = i32::try_from(bound_raw).map_err(|_| { + anyhow::anyhow!( + "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" + ) + })?; + } + "ClosestVectorProblem" => { + let basis_str = args.basis.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "CVP requires --basis, --target-vec\n\n\ + Usage: pred create CVP --basis \"1,0;0,1\" --target-vec \"0.5,0.5\"" + ) + })?; + let target_str = args + .target_vec + .as_deref() + .ok_or_else(|| anyhow::anyhow!("CVP requires --target-vec (e.g., \"0.5,0.5\")"))?; + let basis: Vec> = basis_str + .split(';') + .map(|row| util::parse_comma_list(row.trim())) + .collect::>>()?; + let target: Vec = util::parse_comma_list(target_str)?; + let n = basis.len(); + let bounds = serde_json::from_value(parse_cvp_bounds_value( + args.bounds.as_deref(), + &CreateContext { + num_vertices: None, + num_edges: None, + num_arcs: None, + parsed_fields: BTreeMap::from([( + "basis".to_string(), + serde_json::json!(vec![serde_json::json!([0]); n]), + )]), + }, + )?)?; + let _ = ClosestVectorProblem::new(basis, target, bounds); + } + "ConsecutiveOnesMatrixAugmentation" => { + let matrix = parse_bool_matrix(args)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ + Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" + ) + })?; + ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) + .map_err(anyhow::Error::msg)?; + } + "ConsecutiveBlockMinimization" => { + let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; + let matrix_str = args.matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") + })?; + let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + ConsecutiveBlockMinimization::try_new(matrix, bound) + .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?; + } + "DisjointConnectingPaths" => { + let usage = + "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_terminal_pairs(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "ExactCoverBy3Sets" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "ExactCoverBy3Sets requires --universe and --sets\n\n\ + Usage: pred create X3C --universe 6 --sets \"0,1,2;3,4,5\"" + ) + })?; + if universe % 3 != 0 { + bail!("Universe size must be divisible by 3, got {}", universe); + } + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + if set.len() != 3 { + bail!( + "Subset {} has {} elements, but X3C requires exactly 3 elements per subset", + i, + set.len() + ); + } + if set[0] == set[1] || set[0] == set[2] || set[1] == set[2] { + bail!("Subset {} contains duplicate elements: {:?}", i, set); + } + for &elem in set { + if elem >= universe { + bail!( + "Subset {} contains element {} which is outside universe of size {}", + i, + elem, + universe + ); + } + } + } + } + "IntegralFlowBundles" => { + let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let bundles = parse_bundles(args, num_arcs, usage)?; + let _ = parse_bundle_capacities(args, bundles.len(), usage)?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") + })?; + let sink = args + .sink + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, graph.num_vertices(), usage)?; + validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; + anyhow::ensure!( + source != sink, + "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" + ); + } + "IntegralFlowHomologousArcs" => { + let usage = "Usage: pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\""; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = if let Some(ref s) = args.capacities { + s.split(',') + .map(|token| { + let trimmed = token.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) + }) + .collect::>>()? + } else { + vec![1; num_arcs] + }; + anyhow::ensure!( + capacities.len() == num_arcs, + "Expected {} capacities but got {}\n\n{}", + num_arcs, + capacities.len(), + usage + ); + for (arc_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + anyhow::ensure!( + fits, + "capacity {} at arc index {} is too large for this platform\n\n{}", + capacity, + arc_index, + usage + ); + } + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") + })?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + let homologous_pairs = + parse_homologous_pairs(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + for &(a, b) in &homologous_pairs { + anyhow::ensure!( + a < num_arcs && b < num_arcs, + "homologous pair ({}, {}) references arc >= num_arcs ({})\n\n{}", + a, + b, + num_arcs, + usage + ); + } + } + "IntegralFlowWithMultipliers" => { + let usage = "Usage: pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities_str = args.capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --capacities\n\n{usage}") + })?; + let capacities: Vec = util::parse_comma_list(capacities_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if capacities.len() != num_arcs { + bail!( + "Expected {} capacities but got {}\n\n{}", + num_arcs, + capacities.len(), + usage + ); + } + for (arc_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at arc index {} is too large for this platform\n\n{}", + capacity, + arc_index, + usage + ); + } + } + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers 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!( + "IntegralFlowWithMultipliers requires distinct --source and --sink\n\n{}", + usage + ); + } + let multipliers_str = args.multipliers.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --multipliers\n\n{usage}") + })?; + let multipliers: Vec = util::parse_comma_list(multipliers_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if multipliers.len() != num_vertices { + bail!( + "Expected {} multipliers but got {}\n\n{}", + num_vertices, + multipliers.len(), + usage + ); + } + if multipliers + .iter() + .enumerate() + .any(|(vertex, &multiplier)| vertex != source && vertex != sink && multiplier == 0) + { + bail!("non-terminal multipliers must be positive\n\n{usage}"); + } + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") + })?; + } + "KClique" => { + let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; + } + "KColoring" => { + let usage = "Usage: pred create KColoring --graph 0-1,1-2,2-0 --k 3"; + let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = util::validate_k_param(&resolved_variant, args.k, None, "KColoring") + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "KthBestSpanningTree" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; + let usage = + "Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_edge_weights(args, graph.num_edges())?; + let _ = + util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree") + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = args.bound.ok_or_else(|| { + anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}") + })? as i32; + } + "LengthBoundedDisjointPaths" => { + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") + })?; + let _ = validate_length_bounded_disjoint_paths_args( + graph.num_vertices(), + source, + sink, + bound, + Some(usage), + )?; + } + "LongestPath" => { + let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + if args.weights.is_some() { + bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + } + "MixedChinesePostman" => { + let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; + let graph = parse_mixed_graph(args, usage)?; + let arc_costs = parse_arc_costs(args, graph.num_arcs())?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + if arc_costs.iter().any(|&cost| cost < 0) { + bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); + } + if edge_weights.iter().any(|&weight| weight < 0) { + bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); + } + if resolved_variant.get("weight").map(String::as_str) == Some("One") + && (arc_costs.iter().any(|&cost| cost != 1) + || edge_weights.iter().any(|&weight| weight != 1)) + { + bail!( + "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ + Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." + ); + } + } + "MinMaxMulticenter" => { + let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2"; + let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let vertex_weights = parse_vertex_weights(args, n)?; + let edge_lengths = parse_edge_weights(args, graph.num_edges())?; + let _ = args.k.ok_or_else(|| { + anyhow::anyhow!( + "MinMaxMulticenter requires --k (number of centers)\n\n\ + Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2" + ) + })?; + if vertex_weights.iter().any(|&weight| weight < 0) { + bail!("MinMaxMulticenter --weights must be non-negative"); + } + if edge_lengths.iter().any(|&length| length < 0) { + bail!("MinMaxMulticenter --edge-weights must be non-negative"); + } + } + "MinimumHittingSet" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "MinimumHittingSet requires --universe and --sets\n\n\ + Usage: pred create MinimumHittingSet --universe 6 --sets \"0,1,2;0,3,4;1,3,5;2,4,5;0,1,5;2,3;1,4\"" + ) + })?; + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe { + bail!( + "Set {} contains element {} which is outside universe of size {}", + i, + element, + universe + ); + } + } + } + } + "MinimumMultiwayCut" => { + let usage = + "Usage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_terminals(args, graph.num_vertices())?; + let _ = parse_edge_weights(args, graph.num_edges())?; + } + "MultipleChoiceBranching" => { + let usage = "Usage: 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\" --threshold 10"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") + })?; + let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let _ = parse_arc_weights(args, num_arcs)?; + let _ = parse_partition_groups(args, num_arcs)?; + let _ = parse_multiple_choice_branching_threshold(args, usage)?; + } + "MultipleCopyFileAllocation" => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + let _ = parse_vertex_i64_values( + args.usage.as_deref(), + "usage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + let _ = parse_vertex_i64_values( + args.storage.as_deref(), + "storage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + } + "PathConstrainedNetworkFlow" => { + let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = if let Some(ref s) = args.capacities { + util::parse_comma_list(s)? + } else { + vec![1; num_arcs] + }; + anyhow::ensure!( + capacities.len() == num_arcs, + "capacities length ({}) must match number of arcs ({num_arcs})", + capacities.len() + ); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") + })?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") + })?; + let paths = parse_prescribed_paths(args, num_arcs, usage)?; + validate_prescribed_paths_against_graph(&graph, &paths, source, sink, usage)?; + } + "ProductionPlanning" => { + let usage = "Usage: pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80"; + let num_periods = args.num_periods.ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") + })?; + let demands = parse_named_u64_list( + args.demands.as_deref(), + "ProductionPlanning", + "--demands", + usage, + )?; + let capacities = parse_named_u64_list( + args.capacities.as_deref(), + "ProductionPlanning", + "--capacities", + usage, + )?; + let setup_costs = parse_named_u64_list( + args.setup_costs.as_deref(), + "ProductionPlanning", + "--setup-costs", + usage, + )?; + let production_costs = parse_named_u64_list( + args.production_costs.as_deref(), + "ProductionPlanning", + "--production-costs", + usage, + )?; + let inventory_costs = parse_named_u64_list( + args.inventory_costs.as_deref(), + "ProductionPlanning", + "--inventory-costs", + usage, + )?; + let _ = args.cost_bound.ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --cost-bound\n\n{usage}") + })?; + + for (flag, len) in [ + ("--demands", demands.len()), + ("--capacities", capacities.len()), + ("--setup-costs", setup_costs.len()), + ("--production-costs", production_costs.len()), + ("--inventory-costs", inventory_costs.len()), + ] { + ensure_named_len(len, num_periods, flag, usage)?; + } + } + "SparseMatrixCompression" => { + let matrix = parse_bool_matrix(args)?; + let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" + ) + })?; + let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; + if bound == 0 { + anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); + } + let _ = SparseMatrixCompression::new(matrix, bound); + } + "SequencingToMinimizeMaximumCumulativeCost" => { + let costs_str = args.costs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ + Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" + ) + })?; + let costs: Vec = util::parse_comma_list(costs_str)?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; + validate_precedence_pairs(&precedences, costs.len())?; + } + "SequencingToMinimizeWeightedTardiness" => { + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let weights_str = args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + anyhow::ensure!(bound >= 0, "--bound must be non-negative"); + let lengths: Vec = util::parse_comma_list(lengths_str)?; + let weights: Vec = util::parse_comma_list(weights_str)?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + anyhow::ensure!( + lengths.len() == weights.len(), + "lengths length ({}) must equal weights length ({})", + lengths.len(), + weights.len() + ); + anyhow::ensure!( + lengths.len() == deadlines.len(), + "lengths length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + } + "SequencingWithinIntervals" => { + let usage = + "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; + let rt_str = args.release_times.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") + })?; + let dl_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") + })?; + let len_str = args.lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --lengths\n\n{usage}") + })?; + let release_times: Vec = util::parse_comma_list(rt_str)?; + let deadlines: Vec = util::parse_comma_list(dl_str)?; + let lengths: Vec = util::parse_comma_list(len_str)?; + validate_sequencing_within_intervals_inputs( + &release_times, + &deadlines, + &lengths, + usage, + )?; + } + "SetBasis" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "SetBasis requires --universe, --sets, and --k\n\n\ + Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" + ) + })?; + let _ = args.k.ok_or_else(|| { + anyhow::anyhow!( + "SetBasis requires --k\n\n\ + Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" + ) + })?; + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe { + bail!( + "Set {} contains element {} which is outside universe of size {}", + i, + element, + universe + ); + } + } + } + } + "ShortestWeightConstrainedPath" => { + let usage = "Usage: pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if args.weights.is_some() { + bail!( + "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" + ); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" + ) + })?; + let edge_weights_raw = args.edge_weights.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" + ) + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + let edge_weights = + parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + ensure_positive_i32_values(&edge_weights, "edge weights")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" + ) + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" + ) + })?; + let weight_bound = args.weight_bound.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" + ) + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + ensure_positive_i32(weight_bound, "weight_bound")?; } - ("MinimumMatrixCover", "matrix") => "semicolon-separated i64 rows: \"0,3,1;3,0,2;1,2,0\"", - ("MinimumMatrixDomination", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("MinimumWeightDecoding", "matrix") => "JSON 2D bool array: '[[true,false],[false,true]]'", - ("MinimumWeightDecoding", "target") => "comma-separated booleans: \"true,true,false\"", - ("MinimumWeightSolutionToLinearEquations", "matrix") => { - "JSON 2D integer array: '[[1,2,3],[4,5,6]]'" + "SteinerTree" => { + let usage = "Usage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_edge_weights(args, graph.num_edges())?; + let _ = parse_terminals(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; } - ("MinimumWeightSolutionToLinearEquations", "rhs") => "comma-separated integers: \"5,4\"", - ("FeasibleBasisExtension", "matrix") => "JSON 2D integer array: '[[1,0,1],[0,1,0]]'", - ("FeasibleBasisExtension", "rhs") => "comma-separated integers: \"7,5,3\"", - ("FeasibleBasisExtension", "required_columns") => "comma-separated column indices: \"0,1\"", - ("MinimumCodeGenerationParallelAssignments", "assignments") => { - "semicolon-separated target:reads entries: \"0:1,2;1:0;2:3;3:1,2\"" + "TimetableDesign" => { + let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; + let num_periods = args.num_periods.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") + })?; + let num_craftsmen = args.num_craftsmen.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") + })?; + let num_tasks = args.num_tasks.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") + })?; + let craftsman_avail = + parse_named_bool_rows(args.craftsman_avail.as_deref(), "--craftsman-avail", usage)?; + let task_avail = + parse_named_bool_rows(args.task_avail.as_deref(), "--task-avail", usage)?; + let requirements = parse_timetable_requirements(args.requirements.as_deref(), usage)?; + validate_timetable_design_args( + num_periods, + num_craftsmen, + num_tasks, + &craftsman_avail, + &task_avail, + &requirements, + usage, + )?; } - ("NonTautology", "disjuncts") => "semicolon-separated disjuncts: \"1,2,3;-1,-2,-3\"", - ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { - "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + "UndirectedTwoCommodityIntegralFlow" => { + let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + for (edge_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at edge index {} is too large for this platform\n\n{}", + capacity, + edge_index, + usage + ); + } + } + let num_vertices = graph.num_vertices(); + let source_1 = args.source_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") + })?; + let sink_1 = args.sink_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") + })?; + let source_2 = args.source_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") + })?; + let sink_2 = args.sink_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") + })?; + let _ = args.requirement_1.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" + ) + })?; + let _ = args.requirement_2.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" + ) + })?; + for (label, vertex) in [ + ("source-1", source_1), + ("sink-1", sink_1), + ("source-2", source_2), + ("sink-2", sink_2), + ] { + validate_vertex_index(label, vertex, num_vertices, usage)?; + } } - ("TimetableDesign", "requirements") => "semicolon-separated rows: \"1,0,1;0,1,0\"", - _ => type_format_hint(type_name, graph_type), + _ => {} } -} -fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> Result { - usize::try_from(bound) - .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) + Ok(()) } fn resolve_processor_count_flags( @@ -9852,6 +11221,16 @@ mod tests { assert_eq!(value, serde_json::json!(["Exists", "ForAll", "Exists"])); } + #[test] + fn test_schema_driven_supported_problem_includes_cli_creatable_problem() { + assert!( + schema_driven_supported_problem("ConjunctiveBooleanQuery"), + "all CLI-creatable problems should opt into schema-driven create unless explicitly excluded" + ); + assert!(!schema_driven_supported_problem("ILP")); + assert!(!schema_driven_supported_problem("CircuitSAT")); + } + #[test] fn test_create_schema_driven_builds_job_shop_scheduling() { let cli = Cli::parse_from([ @@ -9951,7 +11330,7 @@ mod tests { } #[test] - fn test_create_falls_back_when_schema_path_is_unsupported() { + fn test_create_schema_driven_builds_conjunctive_boolean_query() { let cli = Cli::parse_from([ "pred", "create", @@ -9968,27 +11347,198 @@ mod tests { panic!("expected create command"); }; - assert!( + let (data, variant) = create_schema_driven(&args, "ConjunctiveBooleanQuery", &BTreeMap::new()) - .expect("schema-driven path should not hard fail") - .is_none(), - "unsupported CBQ schema fields should fall back to the legacy path" + .expect("schema-driven create should parse") + .expect("schema-driven path should support CBQ"); + + let entry = + problemreductions::registry::find_variant_entry("ConjunctiveBooleanQuery", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_variables"], 2); + assert_eq!(data["relations"][0]["arity"], 2); + assert_eq!( + data["conjuncts"][1], + serde_json::json!([0, [{"Variable": 1}, {"Constant": 3}]]) ); + } - let out = OutputConfig { - output: Some(temp_output_path("schema_fallback_cbq")), - quiet: true, - json: false, - auto_json: false, + #[test] + fn test_create_schema_driven_builds_closest_vector_problem_with_default_bounds() { + let cli = Cli::parse_from([ + "pred", + "create", + "CVP", + "--basis", + "1,0;0,1", + "--target-vec", + "0.5,0.5", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); }; - create(&args, &out).expect("legacy fallback should still construct the problem"); - let created: ProblemJsonOutput = - serde_json::from_str(&fs::read_to_string(out.output.as_ref().unwrap()).unwrap()) - .unwrap(); - fs::remove_file(out.output.as_ref().unwrap()).ok(); + let resolved_variant = variant_map(&[("weight", "i32")]); + let (data, variant) = + create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support CVP"); + + let entry = + problemreductions::registry::find_variant_entry("ClosestVectorProblem", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["basis"], serde_json::json!([[1, 0], [0, 1]])); + assert_eq!( + data["bounds"], + serde_json::json!([ + {"lower": -10, "upper": 10}, + {"lower": -10, "upper": 10}, + ]) + ); + } + + #[test] + fn test_create_schema_driven_builds_cdft() { + let cli = Cli::parse_from([ + "pred", + "create", + "ConsistencyOfDatabaseFrequencyTables", + "--num-objects", + "6", + "--attribute-domains", + "2,3,2", + "--frequency-tables", + "0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1", + "--known-values", + "0,0,0;3,0,1;1,2,1", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven( + &args, + "ConsistencyOfDatabaseFrequencyTables", + &BTreeMap::new(), + ) + .expect("schema-driven create should parse") + .expect("schema-driven path should support CDFT"); + + let entry = problemreductions::registry::find_variant_entry( + "ConsistencyOfDatabaseFrequencyTables", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_objects"], 6); + assert_eq!(data["frequency_tables"][0]["attribute_a"], 0); + assert_eq!(data["known_values"][2]["attribute"], 2); + } + + #[test] + fn test_create_schema_driven_builds_balanced_complete_bipartite_subgraph() { + let cli = Cli::parse_from([ + "pred", + "create", + "BalancedCompleteBipartiteSubgraph", + "--left", + "4", + "--right", + "4", + "--biedges", + "0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3", + "--k", + "3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven( + &args, + "BalancedCompleteBipartiteSubgraph", + &BTreeMap::new(), + ) + .expect("schema-driven create should parse") + .expect("schema-driven path should support balanced biclique"); + + let entry = problemreductions::registry::find_variant_entry( + "BalancedCompleteBipartiteSubgraph", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["left_size"], 4); + assert_eq!(data["graph"]["right_size"], 4); + assert_eq!(data["k"], 3); + } + + #[test] + fn test_create_schema_driven_builds_mixed_chinese_postman() { + let cli = Cli::parse_from([ + "pred", + "create", + "MixedChinesePostman/i32", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3,1,2", + "--arc-weights", + "2,3,1,4", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let resolved_variant = variant_map(&[("weight", "i32")]); + let (data, variant) = + create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support mixed chinese postman"); + + let entry = + problemreductions::registry::find_variant_entry("MixedChinesePostman", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["num_vertices"], 5); + assert_eq!(data["arc_weights"], serde_json::json!([2, 3, 1, 4])); + assert_eq!(data["edge_weights"], serde_json::json!([2, 3, 1, 2])); + } - assert_eq!(created.problem_type, "ConjunctiveBooleanQuery"); + #[test] + fn test_create_schema_driven_builds_unit_disk_graph_problem_with_default_radius() { + let cli = Cli::parse_from([ + "pred", + "create", + "MIS/UnitDiskGraph", + "--positions", + "0,0;1,0;0.5,0.8", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let resolved_variant = variant_map(&[("graph", "UnitDiskGraph"), ("weight", "One")]); + let (data, variant) = + create_schema_driven(&args, "MaximumIndependentSet", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support UnitDiskGraph variants"); + + let entry = + problemreductions::registry::find_variant_entry("MaximumIndependentSet", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["positions"].as_array().unwrap().len(), 3); + assert_eq!(data["graph"]["edges"], serde_json::json!([[0, 1], [0, 2], [1, 2]])); } #[test] From 2365cb7c58f49600d08a3d0d4589d9cefe3caf65 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 17:07:01 +0800 Subject: [PATCH 10/13] refactor: remove legacy create match arms --- problemreductions-cli/src/commands/create.rs | 6601 +++--------------- 1 file changed, 989 insertions(+), 5612 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 26f80eed..16001340 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -617,9 +617,7 @@ impl CreateContext { fn seed_field(&mut self, name: &str, value: T) -> Result<()> { let value = serde_json::to_value(value)?; if name == "num_vertices" { - self.num_vertices = value - .as_u64() - .and_then(|raw| usize::try_from(raw).ok()); + self.num_vertices = value.as_u64().and_then(|raw| usize::try_from(raw).ok()); } self.parsed_fields.insert(name.to_string(), value); Ok(()) @@ -712,6 +710,8 @@ fn create_schema_driven( let flag_map = args.flag_map(); let mut context = CreateContext::default(); seed_schema_context_from_cli(args, graph_type, &mut context)?; + validate_schema_driven_semantics(args, canonical, resolved_variant, &serde_json::Value::Null) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; let mut json_map = serde_json::Map::new(); for field in &schema.fields { @@ -730,29 +730,31 @@ fn create_schema_driven( &context, ) { Ok(value) => value, - Err(error) if is_unsupported_schema_parser(&error) => return Ok(None), - Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), + Err(error) => { + return Err(with_schema_usage(error, canonical, resolved_variant)) + } } - } else if let Some(derived) = derive_schema_field_value( - args, - canonical, - &field.name, - &concrete_type, - &context, - )? { + } else if let Some(derived) = + derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? + { derived } else { - return Ok(None); - } - } else if let Some(derived) = derive_schema_field_value( - args, - canonical, - &field.name, - &concrete_type, - &context, - )? { - derived - } else if let Some(raw_value) = raw_value { + return Err(with_schema_usage( + missing_schema_field_error( + canonical, + &field.name, + &field.type_name, + is_geometry, + ), + canonical, + resolved_variant, + )); + } + } else if let Some(derived) = + derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? + { + derived + } else if let Some(raw_value) = raw_value { match parse_schema_field_value( args, canonical, @@ -762,11 +764,14 @@ fn create_schema_driven( &context, ) { Ok(value) => value, - Err(error) if is_unsupported_schema_parser(&error) => return Ok(None), Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), } } else { - return Ok(None); + return Err(with_schema_usage( + missing_schema_field_error(canonical, &field.name, &field.type_name, is_geometry), + canonical, + resolved_variant, + )); }; context.remember(&field.name, &concrete_type, &value); @@ -776,13 +781,45 @@ fn create_schema_driven( let data = serde_json::Value::Object(json_map); validate_schema_driven_semantics(args, canonical, resolved_variant, &data) .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; - if (variant_entry.factory)(data.clone()).is_err() { - return Ok(None); - } + (variant_entry.factory)(data.clone()).map_err(|error| { + with_schema_usage( + anyhow::anyhow!( + "Schema-driven factory rejected generated data for {canonical}: {error}" + ), + canonical, + resolved_variant, + ) + })?; Ok(Some((data, resolved_variant.clone()))) } +fn missing_schema_field_error( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> anyhow::Error { + let display = problem_help_flag_name(canonical, field_name, field_type, is_geometry); + let flags: Vec = display + .split('/') + .filter_map(|part| { + let trimmed = part.trim().trim_start_matches("--"); + (!trimmed.is_empty()).then(|| format!("--{trimmed}")) + }) + .collect(); + let requirement = match flags.as_slice() { + [] => format!("--{}", field_name.replace('_', "-")), + [flag] => flag.clone(), + [first, second] => format!("{first} or {second}"), + _ => { + let last = flags.last().cloned().unwrap_or_default(); + format!("{}, or {}", flags[..flags.len() - 1].join(", "), last) + } + }; + anyhow::anyhow!("{canonical} requires {requirement}") +} + fn parse_schema_field_value( args: &CreateArgs, canonical: &str, @@ -792,6 +829,15 @@ fn parse_schema_field_value( context: &CreateContext, ) -> Result { match (canonical, field_name) { + ("BoyceCoddNormalFormViolation", "functional_deps") => { + let num_attributes = args.n.ok_or_else(|| { + anyhow::anyhow!("BoyceCoddNormalFormViolation requires --n, --sets, and --target") + })?; + Ok(serde_json::to_value(parse_bcnf_functional_deps( + raw, + num_attributes, + )?)?) + } ("BoundedComponentSpanningForest", "max_weight") => { 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 --max-weight 6"; let bound_raw = args.bound.ok_or_else(|| { @@ -852,6 +898,10 @@ fn parse_schema_field_value( })?; Ok(serde_json::json!(max_length)) } + ("LongestCommonSubsequence", "strings") => { + let (strings, _) = parse_lcs_strings(raw)?; + Ok(serde_json::to_value(strings)?) + } ("MinimumDecisionTree", "test_matrix") => { let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { @@ -879,6 +929,11 @@ fn parse_schema_field_value( })?; Ok(serde_json::to_value(matrix)?) } + ("GroupingBySwapping", "string") + | ("StringToStringCorrection", "source") + | ("StringToStringCorrection", "target") => { + Ok(serde_json::to_value(parse_symbol_list_allow_empty(raw)?)?) + } ("MultipleCopyFileAllocation", "usage") => { let (_, num_vertices) = parse_graph(args) .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; @@ -932,15 +987,14 @@ fn schema_field_flag_keys( is_geometry: bool, ) -> Vec { let mut keys = vec![field_name.replace('_', "-")]; - let display_key = problem_help_flag_name(canonical, field_name, field_type, is_geometry); - let display_key = display_key + for display_key in problem_help_flag_name(canonical, field_name, field_type, is_geometry) .split('/') - .next() - .unwrap_or(&display_key) - .trim_start_matches("--") - .to_string(); - if !keys.contains(&display_key) { - keys.push(display_key); + .map(|key| key.trim().trim_start_matches("--").to_string()) + .filter(|key| !key.is_empty()) + { + if !keys.contains(&display_key) { + keys.push(display_key); + } } keys } @@ -1015,8 +1069,13 @@ fn derive_schema_field_value( } if field_name == "graph" && concrete_type == "MixedGraph" { - let usage = format!("Usage: pred create {canonical} {}", example_for(canonical, None)); - return Ok(Some(serde_json::to_value(parse_mixed_graph(args, &usage)?)?)); + let usage = format!( + "Usage: pred create {canonical} {}", + example_for(canonical, None) + ); + return Ok(Some(serde_json::to_value(parse_mixed_graph( + args, &usage, + )?)?)); } if field_name == "graph" && concrete_type == "BipartiteGraph" { @@ -1041,19 +1100,148 @@ fn derive_schema_field_value( && field_name == "bounds" && normalize_type_name(concrete_type) == "Vec" { - return Ok(Some(parse_cvp_bounds_value(args.bounds.as_deref(), context)?)); + return Ok(Some(parse_cvp_bounds_value( + args.bounds.as_deref(), + context, + )?)); } if canonical == "ConjunctiveBooleanQuery" && field_name == "num_variables" && normalize_type_name(concrete_type) == "usize" { - let raw = args.conjuncts_spec.as_deref().ok_or_else(|| { - anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec") - })?; + let raw = args + .conjuncts_spec + .as_deref() + .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec"))?; return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); } + if canonical == "GroupingBySwapping" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args + .string + .as_deref() + .ok_or_else(|| anyhow::anyhow!("GroupingBySwapping requires --string"))?; + let string = parse_symbol_list_allow_empty(raw)?; + let inferred = string.iter().copied().max().map_or(0, |value| value + 1); + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred)))); + } + + if canonical == "JobShopScheduling" + && field_name == "num_processors" + && normalize_type_name(concrete_type) == "usize" + { + let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let inferred_processors = match args.job_tasks.as_deref() { + Some(job_tasks) => { + let jobs = parse_job_shop_jobs(job_tasks)?; + jobs.iter() + .flat_map(|job| job.iter().map(|(processor, _)| *processor)) + .max() + .map(|processor| processor + 1) + } + None => None, + }; + let num_processors = + resolve_processor_count_flags("JobShopScheduling", usage, args.num_processors, args.m)? + .or(inferred_processors) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty job list; use --num-processors" + ) + })?; + return Ok(Some(serde_json::json!(num_processors))); + } + + if canonical == "LongestCommonSubsequence" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args + .strings + .as_deref() + .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; + let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred_alphabet_size)))); + } + + if canonical == "LongestCommonSubsequence" + && field_name == "max_length" + && normalize_type_name(concrete_type) == "usize" + { + let strings: Vec> = + serde_json::from_value(context.parsed_fields.get("strings").cloned().ok_or_else( + || anyhow::anyhow!("LCS max_length derivation requires parsed strings"), + )?)?; + let max_length = strings.iter().map(Vec::len).min().unwrap_or(0); + return Ok(Some(serde_json::json!(max_length))); + } + + if canonical == "QUBO" + && field_name == "num_vars" + && normalize_type_name(concrete_type) == "usize" + { + let matrix = parse_matrix(args)?; + return Ok(Some(serde_json::json!(matrix.len()))); + } + + if canonical == "StringToStringCorrection" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let source = parse_symbol_list_allow_empty(args.source_string.as_deref().unwrap_or(""))?; + let target = parse_symbol_list_allow_empty(args.target_string.as_deref().unwrap_or(""))?; + let inferred = source + .iter() + .chain(target.iter()) + .copied() + .max() + .map_or(0, |value| value + 1); + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred)))); + } + + if field_name == "precedences" + && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" + && args.precedences.is_none() + && args.precedence_pairs.is_none() + { + return Ok(Some(serde_json::json!([]))); + } + + if canonical == "ComparativeContainment" + && matches!(field_name, "r_weights" | "s_weights") + && matches!( + normalize_type_name(concrete_type).as_str(), + "Vec" | "Vec" | "Vec" + ) + { + let sets_len = context + .parsed_fields + .get(match field_name { + "r_weights" => "r_sets", + _ => "s_sets", + }) + .and_then(serde_json::Value::as_array) + .map(Vec::len); + if let Some(len) = sets_len { + let value = match normalize_type_name(concrete_type).as_str() { + "Vec" | "Vec" => serde_json::json!(vec![1_i32; len]), + "Vec" => serde_json::json!(vec![1.0_f64; len]), + _ => unreachable!(), + }; + return Ok(Some(value)); + } + } + if canonical == "ConsistencyOfDatabaseFrequencyTables" && field_name == "known_values" && normalize_type_name(concrete_type) == "Vec" @@ -1103,10 +1291,22 @@ fn derive_schema_default_value( let derived = match field_name { "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), - "arc_weights" if context.num_arcs.is_some() => context.num_arcs.and_then(one_list), + "arc_weights" | "arc_lengths" if context.num_arcs.is_some() => { + context.num_arcs.and_then(one_list) + } "capacities" if canonical == "PathConstrainedNetworkFlow" => { context.num_arcs.and_then(one_list) } + "couplings" if canonical == "SpinGlass" => context.num_edges.and_then(one_list), + "fields" if canonical == "SpinGlass" => match normalized.as_str() { + "Vec" => context + .num_vertices + .map(|len| serde_json::json!(vec![0_i32; len])), + "Vec" => context + .num_vertices + .map(|len| serde_json::json!(vec![0.0_f64; len])), + _ => None, + }, _ => None, }; @@ -1336,11 +1536,15 @@ fn infer_cbq_num_variables(raw: &str) -> Result { conjunct.trim() ) })?; - for arg in args_str.split(',').map(str::trim).filter(|arg| !arg.is_empty()) { + for arg in args_str + .split(',') + .map(str::trim) + .filter(|arg| !arg.is_empty()) + { if let Some(rest) = arg.strip_prefix('v') { - let index: usize = rest.parse().map_err(|err| { - anyhow::anyhow!("Invalid variable index '{rest}': {err}") - })?; + let index: usize = rest + .parse() + .map_err(|err| anyhow::anyhow!("Invalid variable index '{rest}': {err}"))?; num_vars = num_vars.max(index + 1); } } @@ -1349,9 +1553,9 @@ fn infer_cbq_num_variables(raw: &str) -> Result { } fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result> { - let domain_size = context - .usize_field("domain_size") - .ok_or_else(|| anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field"))?; + let domain_size = context.usize_field("domain_size").ok_or_else(|| { + anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field") + })?; raw.split(';') .filter(|entry| !entry.trim().is_empty()) @@ -1393,14 +1597,11 @@ fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result Result)>> { - let relations: Vec = serde_json::from_value( - context - .parsed_fields - .get("relations") - .cloned() - .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior relations field"))?, - ) - .context("Failed to deserialize parsed CBQ relations")?; + let relations: Vec = + serde_json::from_value(context.parsed_fields.get("relations").cloned().ok_or_else( + || anyhow::anyhow!("CBQ conjunct parsing requires prior relations field"), + )?) + .context("Failed to deserialize parsed CBQ relations")?; let domain_size = context .usize_field("domain_size") .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior domain_size field"))?; @@ -1430,18 +1631,18 @@ fn parse_cbq_conjuncts(raw: &str, context: &CreateContext) -> Result= num_variables ({num_variables})" ); Ok(QueryArg::Variable(variable)) } else if let Some(rest) = arg.strip_prefix('c') { - let constant: usize = rest.parse().map_err(|e| { - anyhow::anyhow!("Invalid constant value '{rest}': {e}") - })?; + let constant: usize = rest + .parse() + .map_err(|e| anyhow::anyhow!("Invalid constant value '{rest}': {e}"))?; anyhow::ensure!( constant < domain_size, "Constant {constant} >= domain-size {domain_size}" @@ -1573,6 +1774,91 @@ fn parse_string_list_value(raw: &str) -> Result { Ok(serde_json::to_value(values)?) } +fn parse_symbol_list_allow_empty(raw: &str) -> Result> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(Vec::new()); + } + raw.split(',') + .map(|value| { + value + .trim() + .parse::() + .context("invalid symbol index") + }) + .collect() +} + +fn parse_lcs_strings(raw: &str) -> Result<(Vec>, usize)> { + let segments: Vec<&str> = raw.split(';').map(str::trim).collect(); + let comma_mode = segments.iter().any(|segment| segment.contains(',')); + + if comma_mode { + let strings = segments + .iter() + .map(|segment| parse_symbol_list_allow_empty(segment)) + .collect::>>()?; + let inferred_alphabet_size = strings + .iter() + .flat_map(|string| string.iter()) + .copied() + .max() + .map(|value| value + 1) + .unwrap_or(0); + return Ok((strings, inferred_alphabet_size)); + } + + let mut encoding = BTreeMap::new(); + let mut next_symbol = 0usize; + let strings = segments + .iter() + .map(|segment| { + segment + .as_bytes() + .iter() + .map(|byte| { + let entry = encoding.entry(*byte).or_insert_with(|| { + let current = next_symbol; + next_symbol += 1; + current + }); + *entry + }) + .collect::>() + }) + .collect::>(); + Ok((strings, next_symbol)) +} + +fn parse_bcnf_functional_deps( + raw: &str, + num_attributes: usize, +) -> Result, Vec)>> { + raw.split(';') + .map(|fd_str| { + let parts: Vec<&str> = fd_str.split(':').collect(); + anyhow::ensure!( + parts.len() == 2, + "Each FD must be lhs:rhs, got '{}'", + fd_str + ); + let lhs: Vec = util::parse_comma_list(parts[0])?; + let rhs: Vec = util::parse_comma_list(parts[1])?; + ensure_attribute_indices_in_range( + &lhs, + num_attributes, + &format!("Functional dependency '{fd_str}' lhs"), + )?; + ensure_attribute_indices_in_range( + &rhs, + num_attributes, + &format!("Functional dependency '{fd_str}' rhs"), + )?; + Ok((lhs, rhs)) + }) + .collect() +} + fn parse_cdft_frequency_tables_value( raw: &str, context: &CreateContext, @@ -1589,9 +1875,9 @@ fn parse_cdft_frequency_tables_value( })?, ) .context("Failed to deserialize parsed CDFT attribute domains")?; - let num_objects = context - .usize_field("num_objects") - .ok_or_else(|| anyhow::anyhow!("CDFT frequency table parsing requires prior num_objects field"))?; + let num_objects = context.usize_field("num_objects").ok_or_else(|| { + anyhow::anyhow!("CDFT frequency table parsing requires prior num_objects field") + })?; parse_cdft_frequency_tables(raw, &attribute_domains, num_objects) } @@ -1601,12 +1887,14 @@ fn parse_cdft_known_values_value(raw: &str, context: &CreateContext) -> Result String { match (canonical, field_name) { ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), ("BoundedComponentSpanningForest", "max_weight") => return "max-weight".to_string(), + ("BoyceCoddNormalFormViolation", "num_attributes") => return "n".to_string(), + ("BoyceCoddNormalFormViolation", "functional_deps") => return "sets".to_string(), + ("BoyceCoddNormalFormViolation", "target_subset") => return "target".to_string(), + ("CapacityAssignment", "cost") => return "cost-matrix".to_string(), + ("CapacityAssignment", "delay") => return "delay-matrix".to_string(), ("FlowShopScheduling", "num_processors") | ("JobShopScheduling", "num_processors") | ("OpenShopScheduling", "num_machines") @@ -2371,6 +2664,98 @@ fn validate_schema_driven_semantics( ) })?; } + "CapacityAssignment" => { + let usage = "Usage: pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; + let capacities_str = args.capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "CapacityAssignment requires --capacities, --cost-matrix, --delay-matrix, and --delay-budget\n\n{usage}" + ) + })?; + let cost_matrix_str = args.cost_matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --cost-matrix\n\n{usage}") + })?; + let delay_matrix_str = args.delay_matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --delay-matrix\n\n{usage}") + })?; + let _ = args.delay_budget.ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --delay-budget\n\n{usage}") + })?; + + let capacities: Vec = util::parse_comma_list(capacities_str)?; + anyhow::ensure!( + !capacities.is_empty(), + "CapacityAssignment requires at least one capacity value\n\n{usage}" + ); + anyhow::ensure!( + capacities.iter().all(|&capacity| capacity > 0), + "CapacityAssignment capacities must be positive\n\n{usage}" + ); + anyhow::ensure!( + capacities.windows(2).all(|w| w[0] < w[1]), + "CapacityAssignment capacities must be strictly increasing\n\n{usage}" + ); + + let cost = parse_u64_matrix_rows(cost_matrix_str, "cost")?; + let delay = parse_u64_matrix_rows(delay_matrix_str, "delay")?; + anyhow::ensure!( + cost.len() == delay.len(), + "cost matrix row count ({}) must match delay matrix row count ({})\n\n{usage}", + cost.len(), + delay.len() + ); + + for (index, row) in cost.iter().enumerate() { + anyhow::ensure!( + row.len() == capacities.len(), + "cost row {} length ({}) must match capacities length ({})\n\n{usage}", + index, + row.len(), + capacities.len() + ); + anyhow::ensure!( + row.windows(2).all(|w| w[0] <= w[1]), + "cost row {} must be non-decreasing\n\n{usage}", + index + ); + } + for (index, row) in delay.iter().enumerate() { + anyhow::ensure!( + row.len() == capacities.len(), + "delay row {} length ({}) must match capacities length ({})\n\n{usage}", + index, + row.len(), + capacities.len() + ); + anyhow::ensure!( + row.windows(2).all(|w| w[0] >= w[1]), + "delay row {} must be non-increasing\n\n{usage}", + index + ); + } + } + "BoyceCoddNormalFormViolation" => { + let n = args.n.ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --n, --sets, and --target\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let sets_str = args.sets.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --sets (functional deps as lhs:rhs;...)\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let target_str = args.target.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let _ = parse_bcnf_functional_deps(sets_str, n)?; + let target: Vec = util::parse_comma_list(target_str)?; + ensure_attribute_indices_in_range(&target, n, "Target subset")?; + } "ClosestVectorProblem" => { let basis_str = args.basis.as_deref().ok_or_else(|| { anyhow::anyhow!( @@ -2431,17 +2816,80 @@ fn validate_schema_driven_semantics( ConsecutiveBlockMinimization::try_new(matrix, bound) .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?; } - "DisjointConnectingPaths" => { - let usage = - "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_terminal_pairs(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "ExactCoverBy3Sets" => { + "ComparativeContainment" => { let universe = args.universe.ok_or_else(|| { anyhow::anyhow!( - "ExactCoverBy3Sets requires --universe and --sets\n\n\ + "ComparativeContainment requires --universe, --r-sets, and --s-sets\n\n\ + Usage: pred create ComparativeContainment --universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]" + ) + })?; + let r_sets = parse_named_sets(args.r_sets.as_deref(), "--r-sets")?; + let s_sets = parse_named_sets(args.s_sets.as_deref(), "--s-sets")?; + validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?; + validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?; + match resolved_variant.get("weight").map(|value| value.as_str()) { + Some("One") => { + let r_weights = parse_named_set_weights( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + let s_weights = parse_named_set_weights( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + anyhow::ensure!( + r_weights.iter().all(|&w| w == 1) && s_weights.iter().all(|&w| w == 1), + "Non-unit weights are not supported for ComparativeContainment/One.\n\n\ + Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances." + ); + } + Some("f64") => { + let r_weights = parse_named_set_weights_f64( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; + let s_weights = parse_named_set_weights_f64( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?; + } + Some("i32") | None => { + let r_weights = parse_named_set_weights( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; + let s_weights = parse_named_set_weights( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?; + } + Some(other) => bail!( + "Unsupported ComparativeContainment weight variant: {}", + other + ), + } + } + "DisjointConnectingPaths" => { + let usage = + "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_terminal_pairs(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "ExactCoverBy3Sets" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "ExactCoverBy3Sets requires --universe and --sets\n\n\ Usage: pred create X3C --universe 6 --sets \"0,1,2;3,4,5\"" ) })?; @@ -2472,6 +2920,55 @@ fn validate_schema_driven_semantics( } } } + "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)?; + anyhow::ensure!( + source != sink, + "GeneralizedHex requires distinct --source and --sink\n\n{usage}" + ); + } + "GroupingBySwapping" => { + let usage = + "Usage: pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 [--alphabet-size 3]"; + let string_str = args.string.as_deref().ok_or_else(|| { + anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") + })?; + let bound = parse_nonnegative_usize_bound( + args.bound.ok_or_else(|| { + anyhow::anyhow!("GroupingBySwapping requires --bound\n\n{usage}") + })?, + "GroupingBySwapping", + usage, + )?; + let string = parse_symbol_list_allow_empty(string_str)?; + let inferred = string.iter().copied().max().map_or(0, |value| value + 1); + let alphabet_size = args.alphabet_size.unwrap_or(inferred); + anyhow::ensure!( + alphabet_size >= inferred, + "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", + alphabet_size, + inferred + ); + anyhow::ensure!( + alphabet_size > 0 || string.is_empty(), + "GroupingBySwapping requires a positive alphabet for non-empty strings.\n\n{usage}" + ); + anyhow::ensure!( + !string.is_empty() || bound == 0, + "GroupingBySwapping requires --bound 0 when --string is empty.\n\n{usage}" + ); + } "IntegralFlowBundles" => { let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; let arcs_str = args @@ -2635,6 +3132,50 @@ fn validate_schema_driven_semantics( anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") })?; } + "JobShopScheduling" => { + let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let job_tasks = args + .job_tasks + .as_deref() + .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; + let jobs = parse_job_shop_jobs(job_tasks)?; + let inferred_processors = jobs + .iter() + .flat_map(|job| job.iter().map(|(processor, _)| *processor)) + .max() + .map(|processor| processor + 1); + let num_processors = resolve_processor_count_flags( + "JobShopScheduling", + usage, + args.num_processors, + args.m, + )? + .or(inferred_processors) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty job list; use --num-processors" + ) + })?; + anyhow::ensure!( + num_processors > 0, + "JobShopScheduling requires --num-processors > 0\n\n{usage}" + ); + for (job_index, job) in jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + anyhow::ensure!( + processor < num_processors, + "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" + ); + } + for (task_index, pair) in job.windows(2).enumerate() { + anyhow::ensure!( + pair[0].0 != pair[1].0, + "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", + task_index + 1 + ); + } + } + } "KClique" => { let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; @@ -2652,12 +3193,12 @@ fn validate_schema_driven_semantics( "Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3"; let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; let _ = parse_edge_weights(args, graph.num_edges())?; - let _ = - util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree") - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args.bound.ok_or_else(|| { - anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}") - })? as i32; + let _ = util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree") + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = args + .bound + .ok_or_else(|| anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}"))? + as i32; } "LengthBoundedDisjointPaths" => { let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; @@ -2679,6 +3220,29 @@ fn validate_schema_driven_semantics( Some(usage), )?; } + "LongestCommonSubsequence" => { + let usage = + "Usage: pred create LCS --strings \"010110;100101;001011\" [--alphabet-size 2]"; + let strings_str = args.strings.as_deref().ok_or_else(|| { + anyhow::anyhow!("LongestCommonSubsequence requires --strings\n\n{usage}") + })?; + let (strings, inferred_alphabet_size) = parse_lcs_strings(strings_str)?; + let alphabet_size = args.alphabet_size.unwrap_or(inferred_alphabet_size); + anyhow::ensure!( + alphabet_size >= inferred_alphabet_size, + "--alphabet-size {} is smaller than the inferred alphabet size ({})", + alphabet_size, + inferred_alphabet_size + ); + anyhow::ensure!( + strings.iter().any(|string| !string.is_empty()), + "LongestCommonSubsequence requires at least one non-empty string.\n\n{usage}" + ); + anyhow::ensure!( + alphabet_size > 0, + "LongestCommonSubsequence requires a positive alphabet. Provide --alphabet-size when all strings are empty.\n\n{usage}" + ); + } "LongestPath" => { let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; let (graph, _) = @@ -2740,6 +3304,34 @@ fn validate_schema_driven_semantics( bail!("MinMaxMulticenter --edge-weights must be non-negative"); } } + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" + | "MinimumDominatingSet" + | "MaximalIS" => { + let graph_type = resolved_graph_type(resolved_variant); + let num_vertices = match graph_type { + "KingsSubgraph" | "TriangularSubgraph" => parse_int_positions(args)?.len(), + "UnitDiskGraph" => parse_float_positions(args)?.len(), + _ => { + parse_graph(args) + .map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", + canonical + ) + })? + .1 + } + }; + let weights = parse_vertex_weights(args, num_vertices)?; + reject_nonunit_weights_for_one_variant( + canonical, + graph_type, + resolved_variant, + &weights, + )?; + } "MinimumHittingSet" => { let universe = args.universe.ok_or_else(|| { anyhow::anyhow!( @@ -2761,6 +3353,14 @@ fn validate_schema_driven_semantics( } } } + "MinimumDummyActivitiesPert" => { + let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("MinimumDummyActivitiesPert requires --arcs\n\n{usage}") + })?; + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + let _ = MinimumDummyActivitiesPert::try_new(graph).map_err(anyhow::Error::msg)?; + } "MinimumMultiwayCut" => { let usage = "Usage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]"; @@ -2796,6 +3396,44 @@ fn validate_schema_driven_semantics( MULTIPLE_COPY_FILE_ALLOCATION_USAGE, )?; } + "MultiprocessorScheduling" => { + let usage = "Usage: pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10"; + let lengths_str = args.lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" + ) + })?; + let num_processors = args.num_processors.ok_or_else(|| { + anyhow::anyhow!("MultiprocessorScheduling requires --num-processors\n\n{usage}") + })?; + anyhow::ensure!( + num_processors > 0, + "MultiprocessorScheduling requires --num-processors > 0\n\n{usage}" + ); + let _ = args.deadline.ok_or_else(|| { + anyhow::anyhow!("MultiprocessorScheduling requires --deadline\n\n{usage}") + })?; + let _: Vec = util::parse_comma_list(lengths_str)?; + } + "PartialFeedbackEdgeSet" => { + 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"; + let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = args + .budget + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") + })? + .parse::() + .map_err(|e| { + anyhow::anyhow!( + "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" + ) + })?; + let _ = args.max_cycle_length.ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") + })?; + } "PathConstrainedNetworkFlow" => { let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; let arcs_str = args.arcs.as_deref().ok_or_else(|| { @@ -2874,24 +3512,186 @@ fn validate_schema_driven_semantics( ensure_named_len(len, num_periods, flag, usage)?; } } - "SparseMatrixCompression" => { - let matrix = parse_bool_matrix(args)?; - let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; - let bound = args.bound.ok_or_else(|| { + "SchedulingWithIndividualDeadlines" => { + let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { anyhow::anyhow!( - "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" + "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and a processor count (--num-processors or --m)\n\n{usage}" ) })?; - let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; - if bound == 0 { - anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); - } - let _ = SparseMatrixCompression::new(matrix, bound); - } - "SequencingToMinimizeMaximumCumulativeCost" => { - let costs_str = args.costs.as_deref().ok_or_else(|| { + let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { anyhow::anyhow!( - "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ + "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" + ) + })?; + let num_processors = resolve_processor_count_flags( + "SchedulingWithIndividualDeadlines", + usage, + args.num_processors, + args.m, + )? + .ok_or_else(|| { + anyhow::anyhow!( + "SchedulingWithIndividualDeadlines requires --num-processors or --m\n\n{usage}" + ) + })?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; + anyhow::ensure!( + deadlines.len() == num_tasks, + "deadlines length ({}) must equal num_tasks ({})", + deadlines.len(), + num_tasks + ); + for &(pred, succ) in &precedences { + anyhow::ensure!( + pred < num_tasks && succ < num_tasks, + "precedence index out of range: ({}, {}) but num_tasks = {}", + pred, + succ, + num_tasks + ); + } + let _ = SchedulingWithIndividualDeadlines::new( + num_tasks, + num_processors, + deadlines, + precedences, + ); + } + "StringToStringCorrection" => { + let usage = "Usage: pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2"; + let source_str = args.source_string.as_deref().ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") + })?; + let target_str = args.target_string.as_deref().ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") + })?; + let _ = parse_nonnegative_usize_bound( + args.bound.ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --bound\n\n{usage}") + })?, + "StringToStringCorrection", + usage, + )?; + let source = parse_symbol_list_allow_empty(source_str)?; + let target = parse_symbol_list_allow_empty(target_str)?; + let inferred = source + .iter() + .chain(target.iter()) + .copied() + .max() + .map_or(0, |m| m + 1); + let alphabet_size = args.alphabet_size.unwrap_or(inferred); + anyhow::ensure!( + alphabet_size >= inferred, + "--alphabet-size {} is smaller than max symbol + 1 ({}) in the strings", + alphabet_size, + inferred + ); + } + "SparseMatrixCompression" => { + let matrix = parse_bool_matrix(args)?; + let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" + ) + })?; + let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; + if bound == 0 { + anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); + } + let _ = SparseMatrixCompression::new(matrix, bound); + } + "StackerCrane" => { + let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; + let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let (edges_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + anyhow::ensure!( + edges_graph.num_vertices() == num_vertices, + "internal error: inconsistent graph vertex count" + ); + anyhow::ensure!( + num_vertices == arcs_graph.num_vertices(), + "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" + ); + let arc_lengths = parse_arc_costs(args, num_arcs)?; + let edge_lengths = parse_i32_edge_values( + args.edge_lengths.as_ref(), + edges_graph.num_edges(), + "edge length", + )?; + let _ = problemreductions::models::misc::StackerCrane::try_new( + num_vertices, + arcs_graph.arcs(), + edges_graph.edges(), + arc_lengths, + edge_lengths, + ) + .map_err(|e| anyhow::anyhow!(e))?; + } + "ThreePartition" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --sizes and --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = u64::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "ThreePartition requires a positive integer --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let sizes: Vec = util::parse_comma_list(sizes_str)?; + let _ = ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?; + } + "UndirectedFlowLowerBounds" => { + let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") + })?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + let _ = UndirectedFlowLowerBounds::new( + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + ); + } + "SequencingToMinimizeMaximumCumulativeCost" => { + let costs_str = args.costs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" ) })?; @@ -3587,5519 +4387,97 @@ fn validate_length_bounded_disjoint_paths_args( usage, ) })?; - if source >= num_vertices || sink >= num_vertices { - return Err(lbdp_validation_error( - "--source and --sink must be valid graph vertices", - usage, - )); - } - if source == sink { - return Err(lbdp_validation_error( - "--source and --sink must be distinct", - usage, - )); - } - if max_length == 0 { - return Err(lbdp_validation_error( - "--max-length must be positive", - usage, - )); - } - Ok(max_length) -} - -/// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). -fn resolved_graph_type(variant: &BTreeMap) -> &str { - variant - .get("graph") - .map(|s| s.as_str()) - .unwrap_or("SimpleGraph") -} - -pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { - if args.example.is_some() { - return create_from_example(args, out); - } - - let problem = args.problem.as_ref().ok_or_else(|| { - anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") - })?; - let rgraph = problemreductions::rules::ReductionGraph::new(); - let resolved = match resolve_problem_ref(problem, &rgraph) { - Ok(resolved) => resolved, - Err(graph_err) => match resolve_catalog_problem_ref(problem) { - Ok(catalog_resolved) => { - if rgraph.variants_for(catalog_resolved.name()).is_empty() { - ProblemRef { - name: catalog_resolved.name().to_string(), - variant: catalog_resolved.variant().clone(), - } - } else { - return Err(graph_err); - } - } - Err(catalog_err) => { - let spec = parse_problem_spec(problem)?; - if rgraph.variants_for(&spec.name).is_empty() { - return Err(catalog_err); - } - return Err(graph_err); - } - }, - }; - let canonical = resolved.name.as_str(); - let resolved_variant = resolved.variant.clone(); - let graph_type = resolved_graph_type(&resolved_variant); - - if args.random { - return create_random(args, canonical, &resolved_variant, out); - } - - // ILP and CircuitSAT have complex input structures not suited for CLI flags. - // Check before the empty-flags help so they get a clear message. - if canonical == "ILP" || canonical == "CircuitSAT" { - bail!( - "CLI creation is not yet supported for {canonical}.\n\n\ - {canonical} instances are typically created via reduction:\n\ - pred create MIS --graph 0-1,1-2 | pred reduce - --to {canonical}\n\n\ - Or use the Rust API for direct construction." - ); - } - - // Show schema-driven help when no data flags are provided - if all_data_flags_empty(args) { - print_problem_help(canonical, &resolved_variant)?; - std::process::exit(2); - } - - if let Some((data, variant)) = create_schema_driven(args, canonical, &resolved_variant)? { - let output = ProblemJsonOutput { - problem_type: canonical.to_string(), - variant, - data, - }; - return emit_problem_output(&output, out); - } - - let (data, variant) = match canonical { - // Graph problems with vertex weights - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" => { - create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? - } - - // SteinerTree (graph + edge weights + terminals) - "SteinerTree" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4" - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let terminals = parse_terminals(args, graph.num_vertices())?; - let data = ser(SteinerTree::new(graph, edge_weights, terminals))?; - (data, resolved_variant.clone()) - } - - // 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(), - ) - } - - // DisjointConnectingPaths (graph + terminal pairs) - "DisjointConnectingPaths" => { - let usage = - "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let terminal_pairs = parse_terminal_pairs(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - ( - ser(DisjointConnectingPaths::new(graph, terminal_pairs))?, - resolved_variant.clone(), - ) - } - - // IntegralFlowWithMultipliers (directed arcs + capacities + source/sink + multipliers + requirement) - "IntegralFlowWithMultipliers" => { - let usage = "Usage: pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities_str = args.capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --capacities\n\n{usage}") - })?; - let capacities: Vec = util::parse_comma_list(capacities_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if capacities.len() != num_arcs { - bail!( - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage - ); - } - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage - ); - } - } - - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers 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!( - "IntegralFlowWithMultipliers requires distinct --source and --sink\n\n{}", - usage - ); - } - - let multipliers_str = args.multipliers.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --multipliers\n\n{usage}") - })?; - let multipliers: Vec = util::parse_comma_list(multipliers_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if multipliers.len() != num_vertices { - bail!( - "Expected {} multipliers but got {}\n\n{}", - num_vertices, - multipliers.len(), - usage - ); - } - if multipliers - .iter() - .enumerate() - .any(|(vertex, &multiplier)| vertex != source && vertex != sink && multiplier == 0) - { - bail!("non-terminal multipliers must be positive\n\n{usage}"); - } - - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") - })?; - ( - ser(IntegralFlowWithMultipliers::new( - graph, - source, - sink, - multipliers, - capacities, - requirement, - ))?, - resolved_variant.clone(), - ) - } - - // Minimum cut into bounded sets (graph + edge weights + s/t/B/K) - "MinimumCutIntoBoundedSets" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumCutIntoBoundedSets --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --source 0 --sink 2 --size-bound 2" - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let source = args - .source - .context("--source is required for MinimumCutIntoBoundedSets")?; - let sink = args - .sink - .context("--sink is required for MinimumCutIntoBoundedSets")?; - let size_bound = args - .size_bound - .context("--size-bound is required for MinimumCutIntoBoundedSets")?; - ( - ser(MinimumCutIntoBoundedSets::new( - graph, - edge_weights, - source, - sink, - size_bound, - ))?, - resolved_variant.clone(), - ) - } - - // MaximumAchromaticNumber (graph only, no weights) - "MaximumAchromaticNumber" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MaximumAchromaticNumber --graph 0-1,1-2,2-3,3-4,4-5,5-0" - ) - })?; - ( - ser(problemreductions::models::graph::MaximumAchromaticNumber::new(graph))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MaximumDomaticNumber (graph only, no weights) - "MaximumDomaticNumber" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MaximumDomaticNumber --graph 0-1,1-2,0-2" - ) - })?; - ( - ser(problemreductions::models::graph::MaximumDomaticNumber::new( - graph, - ))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MinimumCoveringByCliques (graph only, no weights) - "MinimumCoveringByCliques" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumCoveringByCliques --graph 0-1,1-2,0-2,2-3" - ) - })?; - ( - ser(problemreductions::models::graph::MinimumCoveringByCliques::new(graph))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MinimumIntersectionGraphBasis (graph only, no weights) - "MinimumIntersectionGraphBasis" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumIntersectionGraphBasis --graph 0-1,1-2" - ) - })?; - ( - ser(problemreductions::models::graph::MinimumIntersectionGraphBasis::new(graph))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MinimumMaximalMatching (graph only, no weights) - "MinimumMaximalMatching" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumMaximalMatching --graph 0-1,1-2,2-3,3-4,4-5" - ) - })?; - ( - ser(MinimumMaximalMatching::new(graph))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // Hamiltonian Circuit (graph only, no weights) - "HamiltonianCircuit" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create HamiltonianCircuit --graph 0-1,1-2,2-3,3-0" - ) - })?; - ( - ser(HamiltonianCircuit::new(graph))?, - resolved_variant.clone(), - ) - } - - // Maximum Leaf Spanning Tree (graph only, no weights) - "MaximumLeafSpanningTree" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MaximumLeafSpanningTree --graph 0-1,0-2,0-3,1-4,2-4,2-5,3-5,4-5,1-3" - ) - })?; - ( - ser(problemreductions::models::graph::MaximumLeafSpanningTree::new(graph))?, - resolved_variant.clone(), - ) - } - - // Biconnectivity augmentation - "BiconnectivityAugmentation" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5" - ) - })?; - let potential_edges = parse_potential_edges(args)?; - validate_potential_edges(&graph, &potential_edges)?; - let budget = parse_budget(args)?; - ( - ser(BiconnectivityAugmentation::new( - graph, - potential_edges, - budget, - ))?, - resolved_variant.clone(), - ) - } - - // Partial Feedback Edge Set - "PartialFeedbackEdgeSet" => { - 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"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let budget = args - .budget - .as_deref() - .ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") - })? - .parse::() - .map_err(|e| { - anyhow::anyhow!( - "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" - ) - })?; - let max_cycle_length = args.max_cycle_length.ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") - })?; - ( - ser(PartialFeedbackEdgeSet::new(graph, budget, max_cycle_length))?, - resolved_variant.clone(), - ) - } - - // Bounded Component Spanning Forest - "BoundedComponentSpanningForest" => { - 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 --max-weight 6"; - let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") - })?; - let weights = parse_vertex_weights(args, n)?; - if weights.iter().any(|&weight| weight < 0) { - bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}"); - } - let max_components = args.k.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}") - })?; - if max_components == 0 { - bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); - } - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") - })?; - if bound_raw <= 0 { - bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); - } - let max_weight = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - ( - ser(BoundedComponentSpanningForest::new( - graph, - weights, - max_components, - max_weight, - ))?, - resolved_variant.clone(), - ) - } - - // Hamiltonian path (graph only, no weights) - "HamiltonianPath" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!("{e}\n\nUsage: pred create HamiltonianPath --graph 0-1,1-2,2-3") - })?; - (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) - } - - // Hamiltonian path between two specified vertices - "HamiltonianPathBetweenTwoVertices" => { - let usage = "pred create HamiltonianPathBetweenTwoVertices --graph 0-1,0-3,1-2,1-4,2-5,3-4,4-5,2-3 --source-vertex 0 --target-vertex 5"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - let source_vertex = args.source_vertex.ok_or_else(|| { - anyhow::anyhow!( - "HamiltonianPathBetweenTwoVertices requires --source-vertex\n\nUsage: {usage}" - ) - })?; - let target_vertex = args.target_vertex.ok_or_else(|| { - anyhow::anyhow!( - "HamiltonianPathBetweenTwoVertices requires --target-vertex\n\nUsage: {usage}" - ) - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - anyhow::ensure!( - source_vertex != target_vertex, - "source_vertex and target_vertex must be distinct" - ); - ( - ser(HamiltonianPathBetweenTwoVertices::new( - graph, - source_vertex, - target_vertex, - ))?, - resolved_variant.clone(), - ) - } - - "GraphPartitioning" => { - let usage = "pred create GraphPartitioning --graph 0-1,1-2,2-3,3-0 --num-partitions 2"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - let num_partitions = args.num_partitions.ok_or_else(|| { - anyhow::anyhow!("GraphPartitioning requires --num-partitions\n\nUsage: {usage}") - })?; - anyhow::ensure!( - num_partitions == 2, - "GraphPartitioning currently models balanced bipartition only, so --num-partitions must be 2 (got {num_partitions})" - ); - ( - ser(GraphPartitioning::new(graph))?, - resolved_variant.clone(), - ) - } - - // LongestPath - "LongestPath" => { - let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - if args.weights.is_some() { - bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); - } - let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - let source_vertex = args.source_vertex.ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") - })?; - let target_vertex = args.target_vertex.ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - ( - ser(LongestPath::new( - graph, - edge_lengths, - source_vertex, - target_vertex, - ))?, - resolved_variant.clone(), - ) - } - - // ShortestWeightConstrainedPath - "ShortestWeightConstrainedPath" => { - let usage = "pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - if args.weights.is_some() { - bail!( - "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" - ); - } - let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" - ) - })?; - let edge_weights_raw = args.edge_weights.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" - ) - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - let edge_weights = - parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - ensure_positive_i32_values(&edge_weights, "edge weights")?; - let source_vertex = args.source_vertex.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" - ) - })?; - let target_vertex = args.target_vertex.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" - ) - })?; - let weight_bound = args.weight_bound.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" - ) - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - ensure_positive_i32(weight_bound, "weight_bound")?; - ( - ser(ShortestWeightConstrainedPath::new( - graph, - edge_lengths, - edge_weights, - source_vertex, - target_vertex, - weight_bound, - ))?, - resolved_variant.clone(), - ) - } - - // MultipleCopyFileAllocation (graph + usage + storage) - "MultipleCopyFileAllocation" => { - let (graph, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - let usage = parse_vertex_i64_values( - args.usage.as_deref(), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - let storage = parse_vertex_i64_values( - args.storage.as_deref(), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - ( - ser(MultipleCopyFileAllocation::new(graph, usage, storage))?, - resolved_variant.clone(), - ) - } - - // ExpectedRetrievalCost (probabilities + sectors) - "ExpectedRetrievalCost" => { - let probabilities_str = args.probabilities.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ExpectedRetrievalCost requires --probabilities\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ) - })?; - let probabilities: Vec = util::parse_comma_list(probabilities_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{EXPECTED_RETRIEVAL_COST_USAGE}"))?; - anyhow::ensure!( - !probabilities.is_empty(), - "ExpectedRetrievalCost requires at least one probability\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ); - anyhow::ensure!( - probabilities.iter().all(|p| p.is_finite() && (0.0..=1.0).contains(p)), - "ExpectedRetrievalCost probabilities must be finite values in [0, 1]\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ); - let total_probability: f64 = probabilities.iter().sum(); - anyhow::ensure!( - (total_probability - 1.0).abs() <= 1e-9, - "ExpectedRetrievalCost probabilities must sum to 1.0\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ); - - let num_sectors = args.num_sectors.ok_or_else(|| { - anyhow::anyhow!( - "ExpectedRetrievalCost requires --num-sectors\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ) - })?; - anyhow::ensure!( - num_sectors >= 2, - "ExpectedRetrievalCost requires at least two sectors\n\n{EXPECTED_RETRIEVAL_COST_USAGE}" - ); - - ( - ser(ExpectedRetrievalCost::new(probabilities, num_sectors))?, - resolved_variant.clone(), - ) - } - - // UndirectedFlowLowerBounds (graph + capacities + lower bounds + terminals + requirement) - "UndirectedFlowLowerBounds" => { - let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") - })?; - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - ( - ser(UndirectedFlowLowerBounds::new( - graph, - capacities, - lower_bounds, - source, - sink, - requirement, - ))?, - resolved_variant.clone(), - ) - } - - // UndirectedTwoCommodityIntegralFlow (graph + capacities + terminals + requirements) - "UndirectedTwoCommodityIntegralFlow" => { - let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - for (edge_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at edge index {} is too large for this platform\n\n{}", - capacity, - edge_index, - usage - ); - } - } - let num_vertices = graph.num_vertices(); - let source_1 = args.source_1.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") - })?; - let sink_1 = args.sink_1.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") - })?; - let source_2 = args.source_2.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") - })?; - let sink_2 = args.sink_2.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") - })?; - let requirement_1 = args.requirement_1.ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" - ) - })?; - let requirement_2 = args.requirement_2.ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" - ) - })?; - for (label, vertex) in [ - ("source-1", source_1), - ("sink-1", sink_1), - ("source-2", source_2), - ("sink-2", sink_2), - ] { - validate_vertex_index(label, vertex, num_vertices, usage)?; - } - ( - ser(UndirectedTwoCommodityIntegralFlow::new( - graph, - capacities, - source_1, - sink_1, - source_2, - sink_2, - requirement_1, - requirement_2, - ))?, - resolved_variant.clone(), - ) - } - - // IntegralFlowBundles (directed graph + bundles + source/sink + requirement) - "IntegralFlowBundles" => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - let bundle_capacities = parse_bundle_capacities(args, bundles.len(), usage)?; - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") - })?; - let sink = args - .sink - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, graph.num_vertices(), usage)?; - validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; - anyhow::ensure!( - source != sink, - "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" - ); - - ( - ser(IntegralFlowBundles::new( - graph, - source, - sink, - bundles, - bundle_capacities, - requirement, - ))?, - resolved_variant.clone(), - ) - } - - // LengthBoundedDisjointPaths (graph + source + sink + bound) - "LengthBoundedDisjointPaths" => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let max_length = validate_length_bounded_disjoint_paths_args( - graph.num_vertices(), - source, - sink, - bound, - Some(usage), - )?; - - ( - ser(LengthBoundedDisjointPaths::new( - graph, source, sink, max_length, - ))?, - resolved_variant.clone(), - ) - } - - // IsomorphicSpanningTree (graph + tree) - "IsomorphicSpanningTree" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create IsomorphicSpanningTree --graph 0-1,1-2,0-2 --tree 0-1,1-2" - ) - })?; - let tree_str = args.tree.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "IsomorphicSpanningTree requires --tree\n\n\ - Usage: pred create IsomorphicSpanningTree --graph 0-1,1-2,0-2 --tree 0-1,1-2" - ) - })?; - let tree_edges: Vec<(usize, usize)> = tree_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('-').collect(); - if parts.len() != 2 { - bail!("Invalid tree edge '{}': expected format u-v", pair.trim()); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - Ok((u, v)) - }) - .collect::>>()?; - let tree_num_vertices = tree_edges - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0) - .max(graph.num_vertices()); - let tree = SimpleGraph::new(tree_num_vertices, tree_edges); - ( - ser(problemreductions::models::graph::IsomorphicSpanningTree::new(graph, tree))?, - resolved_variant.clone(), - ) - } - - // Bounded Diameter Spanning Tree (graph + edge weights + weight bound + diameter bound) - "BoundedDiameterSpanningTree" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let usage = "Usage: pred create BoundedDiameterSpanningTree --graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --edge-weights 1,2,1,1,2,1,1 --weight-bound 5 --diameter-bound 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - ensure_positive_i32_values(&edge_weights, "edge weights")?; - let weight_bound = args.weight_bound.ok_or_else(|| { - anyhow::anyhow!("BoundedDiameterSpanningTree requires --weight-bound\n\n{usage}") - })?; - ensure_positive_i32(weight_bound, "weight_bound")?; - let diameter_bound = args.diameter_bound.ok_or_else(|| { - anyhow::anyhow!("BoundedDiameterSpanningTree requires --diameter-bound\n\n{usage}") - })?; - if diameter_bound == 0 { - bail!("BoundedDiameterSpanningTree requires --diameter-bound >= 1\n\n{usage}"); - } - ( - ser( - problemreductions::models::graph::BoundedDiameterSpanningTree::new( - graph, - edge_weights, - weight_bound, - diameter_bound, - ), - )?, - resolved_variant.clone(), - ) - } - - // KthBestSpanningTree (weighted graph + k + bound) - "KthBestSpanningTree" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let (k, _variant) = - util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree")?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "KthBestSpanningTree requires --bound\n\n\ - Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3" - ) - })? as i32; - ( - ser(problemreductions::models::graph::KthBestSpanningTree::new( - graph, - edge_weights, - k, - bound, - ))?, - resolved_variant.clone(), - ) - } - - // Graph problems with edge weights - "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--edge-weights 1,1,1]", - problem - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let data = match canonical { - "BottleneckTravelingSalesman" => { - ser(BottleneckTravelingSalesman::new(graph, edge_weights))? - } - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - (data, resolved_variant.clone()) - } - - // SteinerTreeInGraphs (graph + edge weights + terminals) - "SteinerTreeInGraphs" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create SteinerTreeInGraphs --graph 0-1,1-2,2-3 --terminals 0,3 [--edge-weights 1,1,1]" - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let terminals = parse_terminals(args, graph.num_vertices())?; - ( - ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, - resolved_variant.clone(), - ) - } - - // RuralPostman - "RuralPostman" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2" - ) - })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let required_edges_str = args.required_edges.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "RuralPostman requires --required-edges\n\n\ - Usage: pred create RuralPostman --graph 0-1,1-2,2-3 --edge-weights 1,1,1 --required-edges 0,2" - ) - })?; - let required_edges: Vec = util::parse_comma_list(required_edges_str)?; - ( - ser(RuralPostman::new(graph, edge_weights, required_edges))?, - resolved_variant.clone(), - ) - } - - // LongestCircuit - "LongestCircuit" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let usage = "pred create LongestCircuit --graph 0-1,1-2,2-3,3-4,4-5,5-0,0-3,1-4,2-5,3-5 --edge-weights 3,2,4,1,5,2,3,2,1,2"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - let edge_lengths = parse_edge_weights(args, graph.num_edges())?; - if edge_lengths.iter().any(|&length| length <= 0) { - bail!("LongestCircuit --edge-weights must be positive (> 0)"); - } - ( - ser(LongestCircuit::new(graph, edge_lengths))?, - resolved_variant.clone(), - ) - } - - // StackerCrane - "StackerCrane" => { - let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; - let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let (edges_graph, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - anyhow::ensure!( - edges_graph.num_vertices() == num_vertices, - "internal error: inconsistent graph vertex count" - ); - anyhow::ensure!( - num_vertices == arcs_graph.num_vertices(), - "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" - ); - let arc_lengths = parse_arc_costs(args, num_arcs)?; - let edge_lengths = parse_i32_edge_values( - args.edge_lengths.as_ref(), - edges_graph.num_edges(), - "edge length", - )?; - ( - ser(StackerCrane::try_new( - num_vertices, - arcs_graph.arcs(), - edges_graph.edges(), - arc_lengths, - edge_lengths, - ) - .map_err(|e| anyhow::anyhow!(e))?)?, - resolved_variant.clone(), - ) - } - - // MultipleChoiceBranching - "MultipleChoiceBranching" => { - let usage = "Usage: 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\" --threshold 10"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let weights = parse_arc_weights(args, num_arcs)?; - let partition = parse_partition_groups(args, num_arcs)?; - let threshold = parse_multiple_choice_branching_threshold(args, usage)?; - ( - ser(MultipleChoiceBranching::new( - graph, weights, partition, threshold, - ))?, - resolved_variant.clone(), - ) - } - - // KColoring - "KColoring" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!("{e}\n\nUsage: pred create KColoring --graph 0-1,1-2,2-0 --k 3") - })?; - let (k, _variant) = - util::validate_k_param(&resolved_variant, args.k, None, "KColoring")?; - util::ser_kcoloring(graph, k)? - } - - "KClique" => { - let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let k = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; - (ser(KClique::new(graph, k))?, resolved_variant.clone()) - } - - "VertexCover" => { - let usage = "Usage: pred create VertexCover --graph 0-1,1-2,0-2,2-3 --k 2"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let k = args - .k - .ok_or_else(|| anyhow::anyhow!("VertexCover requires --k\n\n{usage}"))?; - if k == 0 { - bail!("VertexCover: --k must be positive"); - } - if k > graph.num_vertices() { - bail!("VertexCover: k must be <= graph num_vertices"); - } - (ser(VertexCover::new(graph, k))?, resolved_variant.clone()) - } - - // SAT - "Satisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "Satisfiability requires --num-vars\n\n\ - Usage: pred create SAT --num-vars 3 --clauses \"1,2;-1,3\"" - ) - })?; - let clauses = parse_clauses(args)?; - ( - ser(Satisfiability::new(num_vars, clauses))?, - resolved_variant.clone(), - ) - } - "NAESatisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "NAESatisfiability requires --num-vars\n\n\ - Usage: pred create NAESAT --num-vars 3 --clauses \"1,2,-3;-1,2,3\"" - ) - })?; - let clauses = parse_clauses(args)?; - ( - ser(NAESatisfiability::try_new(num_vars, clauses).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - "KSatisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "KSatisfiability requires --num-vars\n\n\ - Usage: pred create KSAT --num-vars 3 --clauses \"1,2,3;-1,2,-3\"" - ) - })?; - let clauses = parse_clauses(args)?; - let (k, _variant) = - util::validate_k_param(&resolved_variant, args.k, Some(3), "KSatisfiability")?; - util::ser_ksat(num_vars, clauses, k)? - } - - "Maximum2Satisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "Maximum2Satisfiability requires --num-vars\n\n\ - Usage: pred create MAX2SAT --num-vars 4 --clauses \"1,2;1,-2;-1,3;-1,-3\"" - ) - })?; - let clauses = parse_clauses(args)?; - ( - ser(Maximum2Satisfiability::new(num_vars, clauses))?, - resolved_variant.clone(), - ) - } - - "NonTautology" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "NonTautology requires --num-vars\n\n\ - Usage: pred create NonTautology --num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\"" - ) - })?; - ( - ser(NonTautology::new(num_vars, parse_disjuncts(args)?))?, - resolved_variant.clone(), - ) - } - - "OneInThreeSatisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "OneInThreeSatisfiability requires --num-vars\n\n\ - Usage: pred create OneInThreeSatisfiability --num-vars 4 --clauses \"1,2,3;-1,3,4;2,-3,-4\"" - ) - })?; - let clauses = parse_clauses(args)?; - ( - ser(OneInThreeSatisfiability::new(num_vars, clauses))?, - resolved_variant.clone(), - ) - } - - "Planar3Satisfiability" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "Planar3Satisfiability requires --num-vars\n\n\ - Usage: pred create Planar3Satisfiability --num-vars 4 --clauses \"1,2,3;-1,2,4;1,-3,4;-2,3,-4\"" - ) - })?; - let clauses = parse_clauses(args)?; - ( - ser(Planar3Satisfiability::new(num_vars, clauses))?, - resolved_variant.clone(), - ) - } - - // QBF - "QuantifiedBooleanFormulas" => { - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "QuantifiedBooleanFormulas requires --num-vars, --clauses, and --quantifiers\n\n\ - Usage: pred create QBF --num-vars 3 --clauses \"1,2;-1,3\" --quantifiers \"E,A,E\"" - ) - })?; - let clauses = parse_clauses(args)?; - let quantifiers = parse_quantifiers(args, num_vars)?; - ( - ser(QuantifiedBooleanFormulas::new( - num_vars, - quantifiers, - clauses, - ))?, - resolved_variant.clone(), - ) - } - - // QuadraticAssignment - "QuadraticAssignment" => { - let cost_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "QuadraticAssignment requires --matrix (cost) and --distance-matrix\n\n\ - Usage: pred create QAP --matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"" - ) - })?; - let dist_str = args.distance_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "QuadraticAssignment requires --distance-matrix\n\n\ - Usage: pred create QAP --matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"" - ) - })?; - let cost_matrix = parse_i64_matrix(cost_str).context("Invalid cost matrix")?; - let distance_matrix = parse_i64_matrix(dist_str).context("Invalid distance matrix")?; - let n = cost_matrix.len(); - for (i, row) in cost_matrix.iter().enumerate() { - if row.len() != n { - bail!( - "cost matrix must be square: row {i} has {} columns, expected {n}", - row.len() - ); - } - } - let m = distance_matrix.len(); - for (i, row) in distance_matrix.iter().enumerate() { - if row.len() != m { - bail!( - "distance matrix must be square: row {i} has {} columns, expected {m}", - row.len() - ); - } - } - if n > m { - bail!("num_facilities ({n}) must be <= num_locations ({m})"); - } - ( - ser( - problemreductions::models::algebraic::QuadraticAssignment::new( - cost_matrix, - distance_matrix, - ), - )?, - resolved_variant.clone(), - ) - } - - // QUBO - "QUBO" => { - let matrix = parse_matrix(args)?; - (ser(QUBO::from_matrix(matrix))?, resolved_variant.clone()) - } - - // SpinGlass - "SpinGlass" => { - let (graph, n) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create SpinGlass --graph 0-1,1-2 [--couplings 1,1] [--fields 0,0,0]" - ) - })?; - let use_f64 = resolved_variant.get("weight").is_some_and(|w| w == "f64") - || has_float_syntax(&args.couplings) - || has_float_syntax(&args.fields); - if use_f64 { - let couplings = parse_couplings_f64(args, graph.num_edges())?; - let fields = parse_fields_f64(args, n)?; - let mut variant = resolved_variant.clone(); - variant.insert("weight".to_string(), "f64".to_string()); - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - variant, - ) - } else { - let couplings = parse_couplings(args, graph.num_edges())?; - let fields = parse_fields(args, n)?; - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - resolved_variant.clone(), - ) - } - } - - // Factoring - "Factoring" => { - let usage = "Usage: pred create Factoring --target 15 --m 4 --n 4"; - let target = args - .target - .as_deref() - .ok_or_else(|| anyhow::anyhow!("Factoring requires --target\n\n{usage}"))?; - let target: u64 = target - .parse() - .context("Factoring --target must fit in u64")?; - let m = args - .m - .ok_or_else(|| anyhow::anyhow!("Factoring requires --m\n\n{usage}"))?; - let n = args - .n - .ok_or_else(|| anyhow::anyhow!("Factoring requires --n\n\n{usage}"))?; - (ser(Factoring::new(m, n, target))?, resolved_variant.clone()) - } - - // MaximalIS — same as MIS (graph + vertex weights) - "MaximalIS" => { - create_vertex_weight_problem(args, canonical, graph_type, &resolved_variant)? - } - - // BoyceCoddNormalFormViolation - "BoyceCoddNormalFormViolation" => { - let n = args.n.ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --n, --sets, and --target\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let sets_str = args.sets.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --sets (functional deps as lhs:rhs;...)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let target_str = args.target.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let fds: Vec<(Vec, Vec)> = sets_str - .split(';') - .map(|fd_str| { - let parts: Vec<&str> = fd_str.split(':').collect(); - anyhow::ensure!( - parts.len() == 2, - "Each FD must be lhs:rhs, got '{}'", - fd_str - ); - let lhs: Vec = util::parse_comma_list(parts[0])?; - let rhs: Vec = util::parse_comma_list(parts[1])?; - ensure_attribute_indices_in_range( - &lhs, - n, - &format!("Functional dependency '{fd_str}' lhs"), - )?; - ensure_attribute_indices_in_range( - &rhs, - n, - &format!("Functional dependency '{fd_str}' rhs"), - )?; - Ok((lhs, rhs)) - }) - .collect::>()?; - let target: Vec = util::parse_comma_list(target_str)?; - ensure_attribute_indices_in_range(&target, n, "Target subset")?; - ( - ser(BoyceCoddNormalFormViolation::new(n, fds, target))?, - resolved_variant.clone(), - ) - } - - // BinPacking - "BinPacking" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BinPacking requires --sizes and --capacity\n\n\ - Usage: pred create BinPacking --sizes 3,3,2,2 --capacity 5" - ) - })?; - let cap_str = args.capacity.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BinPacking requires --capacity\n\n\ - Usage: pred create BinPacking --sizes 3,3,2,2 --capacity 5" - ) - })?; - let use_f64 = sizes_str.contains('.') || cap_str.contains('.'); - if use_f64 { - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let capacity: f64 = cap_str.parse()?; - let mut variant = resolved_variant.clone(); - variant.insert("weight".to_string(), "f64".to_string()); - (ser(BinPacking::new(sizes, capacity))?, variant) - } else { - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let capacity: i32 = cap_str.parse()?; - ( - ser(BinPacking::new(sizes, capacity))?, - resolved_variant.clone(), - ) - } - } - - // AdditionalKey - "AdditionalKey" => { - let usage = "Usage: pred create AdditionalKey --num-attributes 6 --dependencies \"0,1:2,3;2,3:4,5\" --relation-attrs \"0,1,2,3,4,5\" --known-keys \"0,1;2,3\""; - let num_attributes = args.num_attributes.ok_or_else(|| { - anyhow::anyhow!("AdditionalKey requires --num-attributes\n\n{usage}") - })?; - let deps_str = args.dependencies.as_deref().ok_or_else(|| { - anyhow::anyhow!("AdditionalKey requires --dependencies\n\n{usage}") - })?; - let ra_str = args.relation_attrs.as_deref().ok_or_else(|| { - anyhow::anyhow!("AdditionalKey requires --relation-attrs\n\n{usage}") - })?; - let dependencies: Vec<(Vec, Vec)> = deps_str - .split(';') - .map(|dep| { - let parts: Vec<&str> = dep.trim().split(':').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid dependency format '{}', expected 'lhs:rhs' (e.g., '0,1:2,3')", - dep.trim() - ); - let lhs: Vec = util::parse_comma_list(parts[0].trim())?; - let rhs: Vec = util::parse_comma_list(parts[1].trim())?; - Ok((lhs, rhs)) - }) - .collect::>>()?; - let relation_attrs: Vec = util::parse_comma_list(ra_str)?; - let known_keys: Vec> = match args.known_keys.as_deref() { - Some(s) if !s.is_empty() => s - .split(';') - .map(|k| util::parse_comma_list(k.trim())) - .collect::>>()?, - _ => vec![], - }; - ( - ser(AdditionalKey::new( - num_attributes, - dependencies, - relation_attrs, - known_keys, - ))?, - resolved_variant.clone(), - ) - } - - "ConsistencyOfDatabaseFrequencyTables" => { - let usage = "Usage: pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\""; - let num_objects = args.num_objects.ok_or_else(|| { - anyhow::anyhow!( - "ConsistencyOfDatabaseFrequencyTables requires --num-objects\n\n{usage}" - ) - })?; - let attribute_domains_str = args.attribute_domains.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ConsistencyOfDatabaseFrequencyTables requires --attribute-domains\n\n{usage}" - ) - })?; - let frequency_tables_str = args.frequency_tables.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ConsistencyOfDatabaseFrequencyTables requires --frequency-tables\n\n{usage}" - ) - })?; - - let attribute_domains: Vec = util::parse_comma_list(attribute_domains_str)?; - for (index, &domain_size) in attribute_domains.iter().enumerate() { - anyhow::ensure!( - domain_size > 0, - "attribute domain at index {index} must be positive\n\n{usage}" - ); - } - let frequency_tables = - parse_cdft_frequency_tables(frequency_tables_str, &attribute_domains, num_objects) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let known_values = parse_cdft_known_values( - args.known_values.as_deref(), - num_objects, - &attribute_domains, - ) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - - ( - ser(ConsistencyOfDatabaseFrequencyTables::new( - num_objects, - attribute_domains, - frequency_tables, - known_values, - ))?, - resolved_variant.clone(), - ) - } - - // IntegerKnapsack - "IntegerKnapsack" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "IntegerKnapsack requires --sizes, --values, and --capacity\n\n\ - Usage: pred create IntegerKnapsack --sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15" - ) - })?; - let values_str = args.values.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegerKnapsack requires --values (e.g., 4,5,7,3,9)") - })?; - let cap_str = args - .capacity - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegerKnapsack requires --capacity (e.g., 15)"))?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let values: Vec = util::parse_comma_list(values_str)?; - let capacity: i64 = cap_str.parse()?; - anyhow::ensure!( - sizes.len() == values.len(), - "sizes and values must have the same length, got {} and {}", - sizes.len(), - values.len() - ); - anyhow::ensure!(sizes.iter().all(|&s| s > 0), "all sizes must be positive"); - anyhow::ensure!(values.iter().all(|&v| v > 0), "all values must be positive"); - anyhow::ensure!(capacity >= 0, "capacity must be nonnegative"); - ( - ser(problemreductions::models::set::IntegerKnapsack::new( - sizes, values, capacity, - ))?, - resolved_variant.clone(), - ) - } - - // SubsetSum - "SubsetSum" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SubsetSum requires --sizes and --target\n\n\ - Usage: pred create SubsetSum --sizes 3,7,1,8,2,4 --target 11" - ) - })?; - let target = args.target.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SubsetSum requires --target\n\n\ - Usage: pred create SubsetSum --sizes 3,7,1,8,2,4 --target 11" - ) - })?; - let sizes = util::parse_biguint_list(sizes_str)?; - let target = util::parse_decimal_biguint(target)?; - ( - ser(SubsetSum::new(sizes, target))?, - resolved_variant.clone(), - ) - } - - // SubsetProduct - "SubsetProduct" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SubsetProduct requires --sizes and --target\n\n\ - Usage: pred create SubsetProduct --sizes 2,3,5,7,6,10 --target 210" - ) - })?; - let target = args.target.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SubsetProduct requires --target\n\n\ - Usage: pred create SubsetProduct --sizes 2,3,5,7,6,10 --target 210" - ) - })?; - let sizes = util::parse_biguint_list(sizes_str)?; - let target = util::parse_decimal_biguint(target)?; - ( - ser(SubsetProduct::new(sizes, target))?, - resolved_variant.clone(), - ) - } - - // MinimumAxiomSet - "MinimumAxiomSet" => { - let usage = "Usage: pred create MinimumAxiomSet --n 8 --true-sentences 0,1,2,3,4,5,6,7 --implications \"0>2;0>3;1>4;1>5;2,4>6;3,5>7;6,7>0;6,7>1\""; - let num_sentences = args.n.ok_or_else(|| { - anyhow::anyhow!( - "MinimumAxiomSet requires --n, --true-sentences, and --implications\n\n{usage}" - ) - })?; - let ts_str = args.true_sentences.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumAxiomSet requires --true-sentences\n\n{usage}") - })?; - let imp_str = args.implications.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumAxiomSet requires --implications\n\n{usage}") - })?; - let true_sentences: Vec = ts_str - .split(',') - .map(|s| s.trim().parse::()) - .collect::>() - .context("--true-sentences must be comma-separated usize values")?; - let implications = parse_implications(imp_str).context( - "--implications must be semicolon-separated \"antecedents>consequent\" pairs", - )?; - ( - ser(MinimumAxiomSet::new( - num_sentences, - true_sentences, - implications, - ))?, - resolved_variant.clone(), - ) - } - - // IntegerExpressionMembership - "IntegerExpressionMembership" => { - let usage = "Usage: pred create IntegerExpressionMembership --expression '{\"Sum\":[{\"Atom\":1},{\"Atom\":2}]}' --target 3"; - let expr_str = args.expression.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "IntegerExpressionMembership requires --expression and --target\n\n{usage}" - ) - })?; - let target = args.target.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegerExpressionMembership requires --target\n\n{usage}") - })?; - let target: u64 = target - .parse() - .context("IntegerExpressionMembership --target must be a positive integer")?; - if target == 0 { - anyhow::bail!("IntegerExpressionMembership --target must be > 0"); - } - let expr: IntExpr = serde_json::from_str(expr_str) - .context("IntegerExpressionMembership --expression must be valid JSON representing an IntExpr tree")?; - if !expr.all_atoms_positive() { - anyhow::bail!("IntegerExpressionMembership --expression must contain only positive integers (all Atom values > 0)"); - } - ( - ser(IntegerExpressionMembership::new(expr, target))?, - resolved_variant.clone(), - ) - } - - // Numerical3DimensionalMatching - "Numerical3DimensionalMatching" => { - let w_sizes_str = args.w_sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Numerical3DimensionalMatching requires --w-sizes, --x-sizes, --y-sizes, and --bound\n\n\ - Usage: pred create Numerical3DimensionalMatching --w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15" - ) - })?; - let x_sizes_str = args.x_sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Numerical3DimensionalMatching requires --x-sizes\n\n\ - Usage: pred create Numerical3DimensionalMatching --w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15" - ) - })?; - let y_sizes_str = args.y_sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Numerical3DimensionalMatching requires --y-sizes\n\n\ - Usage: pred create Numerical3DimensionalMatching --w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "Numerical3DimensionalMatching requires --bound\n\n\ - Usage: pred create Numerical3DimensionalMatching --w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15" - ) - })?; - let bound = u64::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "Numerical3DimensionalMatching requires a positive integer --bound\n\n\ - Usage: pred create Numerical3DimensionalMatching --w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15" - ) - })?; - let sizes_w: Vec = util::parse_comma_list(w_sizes_str)?; - let sizes_x: Vec = util::parse_comma_list(x_sizes_str)?; - let sizes_y: Vec = util::parse_comma_list(y_sizes_str)?; - ( - ser( - Numerical3DimensionalMatching::try_new(sizes_w, sizes_x, sizes_y, bound) - .map_err(anyhow::Error::msg)?, - )?, - resolved_variant.clone(), - ) - } - - // NonLivenessFreePetriNet - "NonLivenessFreePetriNet" => { - let usage = "Usage: pred create NonLivenessFreePetriNet --n 4 --m 3 --arcs \"0>0,1>1,2>2\" --output-arcs \"0>1,1>2,2>3\" --initial-marking 1,0,0,0"; - let num_places = args.n.ok_or_else(|| { - anyhow::anyhow!("NonLivenessFreePetriNet requires --n (num_places)\n\n{usage}") - })?; - let num_transitions = args.m.ok_or_else(|| { - anyhow::anyhow!("NonLivenessFreePetriNet requires --m (num_transitions)\n\n{usage}") - })?; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "NonLivenessFreePetriNet requires --arcs (place>transition arcs)\n\n{usage}" - ) - })?; - let output_arcs_str = args.output_arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "NonLivenessFreePetriNet requires --output-arcs (transition>place arcs)\n\n{usage}" - ) - })?; - let marking_str = args.initial_marking.as_deref().ok_or_else(|| { - anyhow::anyhow!("NonLivenessFreePetriNet requires --initial-marking\n\n{usage}") - })?; - - let place_to_transition: Vec<(usize, usize)> = arcs_str - .split(',') - .filter(|s| !s.trim().is_empty()) - .map(|s| { - let parts: Vec<&str> = s.trim().split('>').collect(); - if parts.len() != 2 { - bail!("Invalid arc '{s}', expected 'place>transition'"); - } - let p: usize = parts[0] - .parse() - .with_context(|| format!("Invalid place index in arc '{s}'"))?; - let t: usize = parts[1] - .parse() - .with_context(|| format!("Invalid transition index in arc '{s}'"))?; - Ok((p, t)) - }) - .collect::>()?; - - let transition_to_place: Vec<(usize, usize)> = output_arcs_str - .split(',') - .filter(|s| !s.trim().is_empty()) - .map(|s| { - let parts: Vec<&str> = s.trim().split('>').collect(); - if parts.len() != 2 { - bail!("Invalid output arc '{s}', expected 'transition>place'"); - } - let t: usize = parts[0] - .parse() - .with_context(|| format!("Invalid transition index in output arc '{s}'"))?; - let p: usize = parts[1] - .parse() - .with_context(|| format!("Invalid place index in output arc '{s}'"))?; - Ok((t, p)) - }) - .collect::>()?; - - let initial_marking: Vec = marking_str - .split(',') - .map(|s| { - s.trim() - .parse::() - .with_context(|| format!("Invalid marking value: {s}")) - }) - .collect::>()?; - - ( - ser(NonLivenessFreePetriNet::try_new( - num_places, - num_transitions, - place_to_transition, - transition_to_place, - initial_marking, - ) - .map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // Betweenness - "Betweenness" => { - let n = args.n.ok_or_else(|| { - anyhow::anyhow!( - "Betweenness requires --n and --sets\n\n\ - Usage: pred create Betweenness --n 5 --sets \"0,1,2;2,3,4;0,2,4;1,3,4\"" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Triple {} has {} elements, expected 3 (a,b,c)", - i, - set.len() - ); - } - } - let triples: Vec<(usize, usize, usize)> = - sets.iter().map(|s| (s[0], s[1], s[2])).collect(); - ( - ser(Betweenness::try_new(n, triples).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // CyclicOrdering - "CyclicOrdering" => { - let n = args.n.ok_or_else(|| { - anyhow::anyhow!( - "CyclicOrdering requires --n and --sets\n\n\ - Usage: pred create CyclicOrdering --n 5 --sets \"0,1,2;2,3,0;1,3,4\"" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Triple {} has {} elements, expected 3 (a,b,c)", - i, - set.len() - ); - } - } - let triples: Vec<(usize, usize, usize)> = - sets.iter().map(|s| (s[0], s[1], s[2])).collect(); - ( - ser(CyclicOrdering::try_new(n, triples).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // ThreePartition - "ThreePartition" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --sizes and --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = u64::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "ThreePartition requires a positive integer --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - ( - ser(ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // DynamicStorageAllocation - "DynamicStorageAllocation" => { - let usage = "Usage: pred create DynamicStorageAllocation --release-times 0,0,1,2,3 --deadlines 3,2,4,5,5 --sizes 2,3,1,3,2 --capacity 6"; - let rt_str = args.release_times.as_deref().ok_or_else(|| { - anyhow::anyhow!("DynamicStorageAllocation requires --release-times\n\n{usage}") - })?; - let dl_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!("DynamicStorageAllocation requires --deadlines\n\n{usage}") - })?; - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!("DynamicStorageAllocation requires --sizes\n\n{usage}") - })?; - let cap_str = args.capacity.as_deref().ok_or_else(|| { - anyhow::anyhow!("DynamicStorageAllocation requires --capacity\n\n{usage}") - })?; - let release_times: Vec = util::parse_comma_list(rt_str)?; - let deadlines: Vec = util::parse_comma_list(dl_str)?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let memory_size: usize = cap_str.parse()?; - if release_times.len() != deadlines.len() || release_times.len() != sizes.len() { - bail!("--release-times, --deadlines, and --sizes must have the same length\n\n{usage}"); - } - let items: Vec<(usize, usize, usize)> = release_times - .into_iter() - .zip(deadlines) - .zip(sizes) - .map(|((r, d), s)| (r, d, s)) - .collect(); - ( - ser(DynamicStorageAllocation::try_new(items, memory_size) - .map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // KthLargestMTuple - "KthLargestMTuple" => { - let sets_str = args.sets.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "KthLargestMTuple requires --sets, --k, and --bound\n\n\ - Usage: pred create KthLargestMTuple --sets \"2,5,8;3,6;1,4,7\" --k 14 --bound 12" - ) - })?; - let k_val = args.k.ok_or_else(|| { - anyhow::anyhow!( - "KthLargestMTuple requires --k\n\n\ - Usage: pred create KthLargestMTuple --sets \"2,5,8;3,6;1,4,7\" --k 14 --bound 12" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "KthLargestMTuple requires --bound\n\n\ - Usage: pred create KthLargestMTuple --sets \"2,5,8;3,6;1,4,7\" --k 14 --bound 12" - ) - })?; - let bound = u64::try_from(bound).map_err(|_| { - anyhow::anyhow!("KthLargestMTuple requires a positive integer --bound") - })?; - let sets: Vec> = sets_str - .split(';') - .map(util::parse_comma_list) - .collect::>()?; - ( - ser(KthLargestMTuple::try_new(sets, k_val as u64, bound) - .map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // AlgebraicEquationsOverGF2 - "AlgebraicEquationsOverGF2" => { - let n = args.num_vars.ok_or_else(|| { - anyhow::anyhow!( - "AlgebraicEquationsOverGF2 requires --num-vars and --equations\n\n\ - Usage: pred create AlgebraicEquationsOverGF2 --num-vars 3 --equations \"0,1:2;1,2:0:;0:1:2:\"\n\n\ - Format: semicolons separate equations, colons separate monomials within an equation,\n\ - commas separate variable indices within a monomial, empty monomial = constant 1" - ) - })?; - let eq_str = args.equations.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "AlgebraicEquationsOverGF2 requires --equations\n\n\ - Usage: pred create AlgebraicEquationsOverGF2 --num-vars 3 --equations \"0,1:2;1,2:0:;0:1:2:\"" - ) - })?; - // Parse equations: "0,1:2;1,2:0:;0:1:2:" - // ';' separates equations, ':' separates monomials, ',' separates variables - let equations: Vec>> = eq_str - .split(';') - .map(|eq_s| { - eq_s.split(':') - .map(|mono_s| { - let mono_s = mono_s.trim(); - if mono_s.is_empty() { - Ok(vec![]) // constant 1 - } else { - mono_s - .split(',') - .map(|v| { - v.trim().parse::().map_err(|e| { - anyhow::anyhow!("Invalid variable index '{v}': {e}") - }) - }) - .collect::>>() - } - }) - .collect::>>>() - }) - .collect::>>>>()?; - ( - ser(AlgebraicEquationsOverGF2::new(n, equations).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // QuadraticCongruences - "QuadraticCongruences" => { - let a = args.coeff_a.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticCongruences requires --coeff-a, --coeff-b, and --coeff-c\n\n\ - Usage: pred create QuadraticCongruences --coeff-a 4 --coeff-b 15 --coeff-c 10" - ) - })?; - let b = args.coeff_b.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticCongruences requires --coeff-b\n\n\ - Usage: pred create QuadraticCongruences --coeff-a 4 --coeff-b 15 --coeff-c 10" - ) - })?; - let c = args.coeff_c.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticCongruences requires --coeff-c\n\n\ - Usage: pred create QuadraticCongruences --coeff-a 4 --coeff-b 15 --coeff-c 10" - ) - })?; - ( - ser(QuadraticCongruences::try_new(a, b, c).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // QuadraticDiophantineEquations - "QuadraticDiophantineEquations" => { - let a = args.coeff_a.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticDiophantineEquations requires --coeff-a, --coeff-b, and --coeff-c\n\n\ - Usage: pred create QuadraticDiophantineEquations --coeff-a 3 --coeff-b 5 --coeff-c 53" - ) - })?; - let b = args.coeff_b.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticDiophantineEquations requires --coeff-b\n\n\ - Usage: pred create QuadraticDiophantineEquations --coeff-a 3 --coeff-b 5 --coeff-c 53" - ) - })?; - let c = args.coeff_c.ok_or_else(|| { - anyhow::anyhow!( - "QuadraticDiophantineEquations requires --coeff-c\n\n\ - Usage: pred create QuadraticDiophantineEquations --coeff-a 3 --coeff-b 5 --coeff-c 53" - ) - })?; - ( - ser(QuadraticDiophantineEquations::try_new(a, b, c).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // SimultaneousIncongruences - "SimultaneousIncongruences" => { - let pairs_str = args.pairs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SimultaneousIncongruences requires --pairs\n\n\ - Usage: pred create SimultaneousIncongruences --pairs \"2,2;1,3;2,5;3,7\"" - ) - })?; - let pairs: Vec<(u64, u64)> = pairs_str - .split(';') - .map(|s| { - let parts: Vec<&str> = s.split(',').collect(); - if parts.len() != 2 { - return Err(anyhow::anyhow!( - "Each pair must be in \"a,b\" format, got: {s}" - )); - } - let a: u64 = parts[0] - .trim() - .parse() - .with_context(|| format!("Invalid integer in pair: {s}"))?; - let b: u64 = parts[1] - .trim() - .parse() - .with_context(|| format!("Invalid integer in pair: {s}"))?; - Ok((a, b)) - }) - .collect::>()?; - ( - ser(SimultaneousIncongruences::new(pairs).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // SumOfSquaresPartition - "SumOfSquaresPartition" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SumOfSquaresPartition requires --sizes and --num-groups\n\n\ - Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3" - ) - })?; - let num_groups = args.num_groups.ok_or_else(|| { - anyhow::anyhow!( - "SumOfSquaresPartition requires --num-groups\n\n\ - Usage: pred create SumOfSquaresPartition --sizes 5,3,8,2,7,1 --num-groups 3" - ) - })?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - ( - ser(SumOfSquaresPartition::try_new(sizes, num_groups) - .map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // PaintShop - "PaintShop" => { - let seq_str = args.sequence.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "PaintShop requires --sequence\n\n\ - Usage: pred create PaintShop --sequence a,b,a,c,c,b" - ) - })?; - let sequence: Vec = seq_str.split(',').map(|s| s.trim().to_string()).collect(); - (ser(PaintShop::new(sequence))?, resolved_variant.clone()) - } - - // MaximumSetPacking - "MaximumSetPacking" => { - let sets = parse_sets(args)?; - let num_sets = sets.len(); - let weights = parse_set_weights(args, num_sets)?; - ( - ser(MaximumSetPacking::with_weights(sets, weights))?, - resolved_variant.clone(), - ) - } - - // SetSplitting - "SetSplitting" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "SetSplitting requires --universe and --sets\n\n\ - Usage: pred create SetSplitting --universe 6 --sets \"0,1,2;2,3,4;0,4,5;1,3,5\"" - ) - })?; - let subsets = parse_sets(args)?; - ( - ser(SetSplitting::try_new(universe, subsets).map_err(anyhow::Error::msg)?)?, - resolved_variant.clone(), - ) - } - - // MinimumHittingSet - "MinimumHittingSet" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "MinimumHittingSet requires --universe and --sets\n\n\ - Usage: pred create MinimumHittingSet --universe 6 --sets \"0,1,2;0,3,4;1,3,5;2,4,5;0,1,5;2,3;1,4\"" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } - } - ( - ser(MinimumHittingSet::new(universe, sets))?, - resolved_variant.clone(), - ) - } - - // MinimumSetCovering - "MinimumSetCovering" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "MinimumSetCovering requires --universe and --sets\n\n\ - Usage: pred create MinimumSetCovering --universe 4 --sets \"0,1;1,2;2,3;0,3\"" - ) - })?; - let sets = parse_sets(args)?; - let num_sets = sets.len(); - let weights = parse_set_weights(args, num_sets)?; - ( - ser(MinimumSetCovering::with_weights(universe, sets, weights))?, - resolved_variant.clone(), - ) - } - - // EnsembleComputation - "EnsembleComputation" => { - let usage = - "Usage: pred create EnsembleComputation --universe 4 --sets \"0,1,2;0,1,3\" [--budget 4]"; - let universe_size = args.universe.ok_or_else(|| { - anyhow::anyhow!("EnsembleComputation requires --universe\n\n{usage}") - })?; - let subsets = parse_sets(args)?; - let instance = if let Some(budget_str) = args.budget.as_deref() { - let budget = budget_str.parse::().map_err(|e| { - anyhow::anyhow!( - "Invalid --budget value for EnsembleComputation: {e}\n\n{usage}" - ) - })?; - EnsembleComputation::try_new(universe_size, subsets, budget) - .map_err(anyhow::Error::msg)? - } else { - EnsembleComputation::with_default_budget(universe_size, subsets) - }; - (ser(instance)?, resolved_variant.clone()) - } - - // ComparativeContainment - "ComparativeContainment" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ComparativeContainment requires --universe, --r-sets, and --s-sets\n\n\ - Usage: pred create ComparativeContainment --universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]" - ) - })?; - let r_sets = parse_named_sets(args.r_sets.as_deref(), "--r-sets")?; - let s_sets = parse_named_sets(args.s_sets.as_deref(), "--s-sets")?; - validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?; - validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?; - let data = match resolved_variant.get("weight").map(|value| value.as_str()) { - Some("One") => { - let r_weights = parse_named_set_weights( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - let s_weights = parse_named_set_weights( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - if r_weights.iter().any(|&w| w != 1) || s_weights.iter().any(|&w| w != 1) { - bail!( - "Non-unit weights are not supported for ComparativeContainment/One.\n\n\ - Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances." - ); - } - ser(ComparativeContainment::::new(universe, r_sets, s_sets))? - } - Some("f64") => { - let r_weights = parse_named_set_weights_f64( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights_f64( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?; - ser(ComparativeContainment::::with_weights( - universe, r_sets, s_sets, r_weights, s_weights, - ))? - } - Some("i32") | None => { - let r_weights = parse_named_set_weights( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?; - ser(ComparativeContainment::with_weights( - universe, r_sets, s_sets, r_weights, s_weights, - ))? - } - Some(other) => bail!( - "Unsupported ComparativeContainment weight variant: {}", - other - ), - }; - (data, resolved_variant.clone()) - } - - // ExactCoverBy3Sets - "ExactCoverBy3Sets" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ExactCoverBy3Sets requires --universe and --sets\n\n\ - Usage: pred create X3C --universe 6 --sets \"0,1,2;3,4,5\"" - ) - })?; - if universe % 3 != 0 { - bail!("Universe size must be divisible by 3, got {}", universe); - } - let sets = parse_sets(args)?; - // Validate each set has exactly 3 distinct elements within the universe - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Subset {} has {} elements, but X3C requires exactly 3 elements per subset", - i, - set.len() - ); - } - if set[0] == set[1] || set[0] == set[2] || set[1] == set[2] { - bail!("Subset {} contains duplicate elements: {:?}", i, set); - } - for &elem in set { - if elem >= universe { - bail!( - "Subset {} contains element {} which is outside universe of size {}", - i, - elem, - universe - ); - } - } - } - let subsets: Vec<[usize; 3]> = sets.into_iter().map(|s| [s[0], s[1], s[2]]).collect(); - ( - ser(problemreductions::models::set::ExactCoverBy3Sets::new( - universe, subsets, - ))?, - resolved_variant.clone(), - ) - } - - // ThreeDimensionalMatching - "ThreeDimensionalMatching" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ThreeDimensionalMatching requires --universe and --sets\n\n\ - Usage: pred create 3DM --universe 3 --sets \"0,1,2;1,0,1;2,2,0\"" - ) - })?; - let sets = parse_sets(args)?; - // Validate each set has exactly 3 elements representing (w, x, y) coordinates - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Triple {} has {} elements, expected 3 (w,x,y)", - i, - set.len() - ); - } - for (coord_idx, &elem) in set.iter().enumerate() { - let coord_name = ["w", "x", "y"][coord_idx]; - if elem >= universe { - bail!( - "Triple {} has {}-coordinate {} which is outside 0..{}", - i, - coord_name, - elem, - universe - ); - } - } - } - let triples: Vec<(usize, usize, usize)> = - sets.into_iter().map(|s| (s[0], s[1], s[2])).collect(); - ( - ser( - problemreductions::models::set::ThreeDimensionalMatching::new( - universe, triples, - ), - )?, - resolved_variant.clone(), - ) - } - - // ThreeMatroidIntersection - "ThreeMatroidIntersection" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ThreeMatroidIntersection requires --universe, --partitions, and --bound\n\n\ - Usage: pred create ThreeMatroidIntersection --universe 6 --partitions \"0,1,2;3,4,5|0,3;1,4;2,5|0,4;1,5;2,3\" --bound 2" - ) - })?; - let bound_val = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ThreeMatroidIntersection requires --bound\n\n\ - Usage: pred create ThreeMatroidIntersection --universe 6 --partitions \"0,1,2;3,4,5|0,3;1,4;2,5|0,4;1,5;2,3\" --bound 2" - ) - })?; - let bound = usize::try_from(bound_val) - .map_err(|_| anyhow::anyhow!("--bound must be non-negative, got {}", bound_val))?; - let partitions_str = args.partitions.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ThreeMatroidIntersection requires --partitions\n\n\ - Usage: pred create ThreeMatroidIntersection --universe 6 --partitions \"0,1,2;3,4,5|0,3;1,4;2,5|0,4;1,5;2,3\" --bound 2" - ) - })?; - let matroids: Vec>> = partitions_str - .split('|') - .map(|matroid_str| { - matroid_str - .split(';') - .map(|group_str| { - group_str - .split(',') - .map(|s| { - s.trim().parse::().map_err(|_| { - anyhow::anyhow!( - "Invalid element in partitions: '{}'", - s.trim() - ) - }) - }) - .collect::>>() - }) - .collect::>>() - }) - .collect::>>()?; - if matroids.len() != 3 { - bail!( - "Expected exactly 3 partition matroids separated by '|', got {}", - matroids.len() - ); - } - ( - ser( - problemreductions::models::set::ThreeMatroidIntersection::new( - universe, matroids, bound, - ), - )?, - resolved_variant.clone(), - ) - } - - // SetBasis - "SetBasis" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --universe, --sets, and --k\n\n\ - Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --k\n\n\ - Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } - } - ( - ser(problemreductions::models::set::SetBasis::new( - universe, sets, k, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumCardinalityKey - "MinimumCardinalityKey" => { - let num_attributes = args.num_attributes.ok_or_else(|| { - anyhow::anyhow!( - "MinimumCardinalityKey requires --num-attributes and --dependencies\n\n\ - Usage: pred create MinimumCardinalityKey --num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\"" - ) - })?; - let deps_str = args.dependencies.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumCardinalityKey requires --dependencies (e.g., \"0,1>2;0,2>3\")" - ) - })?; - let dependencies = parse_dependencies(deps_str)?; - ( - ser(problemreductions::models::set::MinimumCardinalityKey::new( - num_attributes, - dependencies, - ))?, - resolved_variant.clone(), - ) - } - - // TwoDimensionalConsecutiveSets - "TwoDimensionalConsecutiveSets" => { - let alphabet_size = args.alphabet_size.or(args.universe).ok_or_else(|| { - anyhow::anyhow!( - "TwoDimensionalConsecutiveSets requires --alphabet-size (or --universe) and --sets\n\n\ - Usage: pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets \"0,1,2;3,4,5;1,3;2,4;0,5\"" - ) - })?; - let sets = parse_sets(args)?; - ( - ser( - problemreductions::models::set::TwoDimensionalConsecutiveSets::try_new( - alphabet_size, - sets, - ) - .map_err(anyhow::Error::msg)?, - )?, - resolved_variant.clone(), - ) - } - - // RootedTreeStorageAssignment - "RootedTreeStorageAssignment" => { - let usage = - "Usage: pred create RootedTreeStorageAssignment --universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1"; - let universe_size = args.universe.ok_or_else(|| { - anyhow::anyhow!("RootedTreeStorageAssignment requires --universe\n\n{usage}") - })?; - let subsets = parse_sets(args)?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("RootedTreeStorageAssignment requires --bound\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound(bound, "RootedTreeStorageAssignment", usage)?; - ( - ser( - problemreductions::models::set::RootedTreeStorageAssignment::try_new( - universe_size, - subsets, - bound, - ) - .map_err(anyhow::Error::msg)?, - )?, - resolved_variant.clone(), - ) - } - - // BicliqueCover - "BicliqueCover" => { - let usage = "pred create BicliqueCover --left 2 --right 2 --biedges 0-0,0-1,1-1 --k 2"; - let (graph, k) = - parse_bipartite_problem_input(args, "BicliqueCover", "number of bicliques", usage)?; - (ser(BicliqueCover::new(graph, k))?, resolved_variant.clone()) - } - - // BalancedCompleteBipartiteSubgraph - "BalancedCompleteBipartiteSubgraph" => { - let usage = "pred create BalancedCompleteBipartiteSubgraph --left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3"; - let (graph, k) = parse_bipartite_problem_input( - args, - "BalancedCompleteBipartiteSubgraph", - "balanced biclique size", - usage, - )?; - ( - ser(BalancedCompleteBipartiteSubgraph::new(graph, k))?, - resolved_variant.clone(), - ) - } - - // BMF - "BMF" => { - let matrix = parse_bool_matrix(args)?; - let rank = args.rank.ok_or_else(|| { - anyhow::anyhow!( - "BMF requires --matrix and --rank\n\n\ - Usage: pred create BMF --matrix \"1,0;0,1;1,1\" --rank 2" - ) - })?; - (ser(BMF::new(matrix, rank))?, resolved_variant.clone()) - } - - // ConsecutiveBlockMinimization - "ConsecutiveBlockMinimization" => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - ( - ser(ConsecutiveBlockMinimization::try_new(matrix, bound) - .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?)?, - resolved_variant.clone(), - ) - } - - // RectilinearPictureCompression - "RectilinearPictureCompression" => { - let matrix = parse_bool_matrix(args)?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "RectilinearPictureCompression requires --matrix and --bound\n\n\ - Usage: pred create RectilinearPictureCompression --matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --bound 2" - ) - })?; - ( - ser(RectilinearPictureCompression::new(matrix, bound))?, - resolved_variant.clone(), - ) - } - - // ConsecutiveOnesSubmatrix - "ConsecutiveOnesSubmatrix" => { - let matrix = parse_bool_matrix(args)?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveOnesSubmatrix requires --matrix and --bound\n\n\ - Usage: pred create ConsecutiveOnesSubmatrix --matrix \"1,1,0,1;1,0,1,1;0,1,1,0\" --bound 3" - ) - })?; - ( - ser(ConsecutiveOnesSubmatrix::new(matrix, bound))?, - resolved_variant.clone(), - ) - } - - // ConsecutiveOnesMatrixAugmentation - "ConsecutiveOnesMatrixAugmentation" => { - let matrix = parse_bool_matrix(args)?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ - Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" - ) - })?; - ( - ser(ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) - .map_err(|e| anyhow::anyhow!(e))?)?, - resolved_variant.clone(), - ) - } - - // SparseMatrixCompression - "SparseMatrixCompression" => { - let matrix = parse_bool_matrix(args)?; - let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" - ) - })?; - let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; - if bound == 0 { - anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); - } - ( - ser(SparseMatrixCompression::new(matrix, bound))?, - resolved_variant.clone(), - ) - } - - // MaximumLikelihoodRanking - "MaximumLikelihoodRanking" => { - let usage = "Usage: pred create MaximumLikelihoodRanking --matrix \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\""; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MaximumLikelihoodRanking requires --matrix (semicolon-separated i32 rows)\n\n{usage}" - ) - })?; - let matrix_i64 = parse_i64_matrix(matrix_str).context("Invalid matrix")?; - let matrix: Vec> = matrix_i64 - .into_iter() - .map(|row| { - row.into_iter() - .map(|v| { - i32::try_from(v) - .map_err(|_| anyhow::anyhow!("matrix value {v} out of i32 range")) - }) - .collect::>>() - }) - .collect::>>()?; - ( - ser(MaximumLikelihoodRanking::new(matrix))?, - resolved_variant.clone(), - ) - } - - // MinimumMatrixCover - "MinimumMatrixCover" => { - let usage = "Usage: pred create MinimumMatrixCover --matrix \"0,3,1,0;3,0,0,2;1,0,0,4;0,2,4,0\""; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumMatrixCover requires --matrix (semicolon-separated i64 rows)\n\n{usage}" - ) - })?; - let matrix = parse_i64_matrix(matrix_str).context("Invalid matrix")?; - ( - ser(MinimumMatrixCover::new(matrix))?, - resolved_variant.clone(), - ) - } - - // MinimumMatrixDomination - "MinimumMatrixDomination" => { - let matrix = parse_bool_matrix(args)?; - ( - ser(MinimumMatrixDomination::new(matrix))?, - resolved_variant.clone(), - ) - } - - // MinimumWeightDecoding - "MinimumWeightDecoding" => { - let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --matrix (JSON 2D bool array) and --rhs\n\n{usage}" - ) - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - let rhs_str = args.rhs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --rhs (comma-separated booleans)\n\n{usage}" - ) - })?; - let target: Vec = rhs_str - .split(',') - .map(|s| match s.trim() { - "true" | "1" => Ok(true), - "false" | "0" => Ok(false), - other => Err(anyhow::anyhow!("invalid boolean value: {other}")), - }) - .collect::, _>>() - .map_err(|err| { - anyhow::anyhow!( - "Failed to parse --rhs as comma-separated booleans: {err}\n\n{usage}" - ) - })?; - ( - ser(MinimumWeightDecoding::new(matrix, target))?, - resolved_variant.clone(), - ) - } - - // MinimumWeightSolutionToLinearEquations - "MinimumWeightSolutionToLinearEquations" => { - let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --matrix (JSON 2D i64 array) and --rhs\n\n{usage}" - ) - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - let rhs_str = args.rhs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --rhs (comma-separated integers)\n\n{usage}" - ) - })?; - let rhs: Vec = rhs_str - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>() - .map_err(|err| { - anyhow::anyhow!( - "Failed to parse --rhs as comma-separated integers: {err}\n\n{usage}" - ) - })?; - ( - ser(MinimumWeightSolutionToLinearEquations::new(matrix, rhs))?, - resolved_variant.clone(), - ) - } - - // FeasibleBasisExtension - "FeasibleBasisExtension" => { - let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --matrix (JSON 2D i64 array), --rhs, and --required-columns\n\n{usage}" - ) - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - let rhs_str = args.rhs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --rhs (comma-separated integers)\n\n{usage}" - ) - })?; - let rhs: Vec = rhs_str - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>() - .map_err(|err| { - anyhow::anyhow!( - "Failed to parse --rhs as comma-separated integers: {err}\n\n{usage}" - ) - })?; - let required_str = args.required_columns.as_deref().unwrap_or(""); - let required_columns: Vec = if required_str.is_empty() { - vec![] - } else { - required_str - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>() - .map_err(|err| { - anyhow::anyhow!( - "Failed to parse --required-columns as comma-separated indices: {err}\n\n{usage}" - ) - })? - }; - ( - ser(FeasibleBasisExtension::new(matrix, rhs, required_columns))?, - resolved_variant.clone(), - ) - } - - // LongestCommonSubsequence - "LongestCommonSubsequence" => { - let usage = - "Usage: pred create LCS --strings \"010110;100101;001011\" [--alphabet-size 2]"; - let strings_str = args.strings.as_deref().ok_or_else(|| { - anyhow::anyhow!("LongestCommonSubsequence requires --strings\n\n{usage}") - })?; - - let segments: Vec<&str> = strings_str.split(';').map(str::trim).collect(); - let comma_mode = segments.iter().any(|segment| segment.contains(',')); - - let (strings, inferred_alphabet_size): (Vec>, usize) = if comma_mode { - let strings = segments - .iter() - .map(|segment| { - if segment.is_empty() { - return Ok(Vec::new()); - } - segment - .split(',') - .map(|value| { - value.trim().parse::().map_err(|e| { - anyhow::anyhow!("Invalid LCS alphabet index: {}", e) - }) - }) - .collect::>>() - }) - .collect::>>()?; - let inferred = strings - .iter() - .flat_map(|string| string.iter()) - .copied() - .max() - .map(|value| value + 1) - .unwrap_or(0); - (strings, inferred) - } else { - let mut encoding = BTreeMap::new(); - let mut next_symbol = 0usize; - let strings = segments - .iter() - .map(|segment| { - segment - .as_bytes() - .iter() - .map(|byte| { - let entry = encoding.entry(*byte).or_insert_with(|| { - let current = next_symbol; - next_symbol += 1; - current - }); - *entry - }) - .collect::>() - }) - .collect::>(); - (strings, next_symbol) - }; - - let alphabet_size = args.alphabet_size.unwrap_or(inferred_alphabet_size); - anyhow::ensure!( - alphabet_size >= inferred_alphabet_size, - "--alphabet-size {} is smaller than the inferred alphabet size ({})", - alphabet_size, - inferred_alphabet_size - ); - anyhow::ensure!( - strings.iter().any(|string| !string.is_empty()), - "LongestCommonSubsequence requires at least one non-empty string.\n\n{usage}" - ); - anyhow::ensure!( - alphabet_size > 0, - "LongestCommonSubsequence requires a positive alphabet. Provide --alphabet-size when all strings are empty.\n\n{usage}" - ); - ( - ser(LongestCommonSubsequence::new(alphabet_size, strings))?, - resolved_variant.clone(), - ) - } - - // GroupingBySwapping - "GroupingBySwapping" => { - let usage = - "Usage: pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 [--alphabet-size 3]"; - let string_str = args.string.as_deref().ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound( - args.bound.ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --bound\n\n{usage}") - })?, - "GroupingBySwapping", - usage, - )?; - - let string = if string_str.trim().is_empty() { - Vec::new() - } else { - string_str - .split(',') - .map(|value| { - value - .trim() - .parse::() - .context("invalid symbol index") - }) - .collect::>>()? - }; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", - alphabet_size, - inferred - ); - anyhow::ensure!( - alphabet_size > 0 || string.is_empty(), - "GroupingBySwapping requires a positive alphabet for non-empty strings.\n\n{usage}" - ); - anyhow::ensure!( - !string.is_empty() || bound == 0, - "GroupingBySwapping requires --bound 0 when --string is empty.\n\n{usage}" - ); - ( - ser(GroupingBySwapping::new(alphabet_size, string, bound))?, - resolved_variant.clone(), - ) - } - - // MinimumExternalMacroDataCompression - "MinimumExternalMacroDataCompression" => { - let usage = "Usage: pred create MinimumExternalMacroDataCompression --string \"0,1,0,1\" --pointer-cost 2 [--alphabet-size 2]"; - let string_str = args.string.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumExternalMacroDataCompression requires --string\n\n{usage}") - })?; - let pointer_cost = args.pointer_cost.ok_or_else(|| { - anyhow::anyhow!( - "MinimumExternalMacroDataCompression requires --pointer-cost\n\n{usage}" - ) - })?; - anyhow::ensure!( - pointer_cost > 0, - "--pointer-cost must be a positive integer\n\n{usage}" - ); - - let string: Vec = if string_str.trim().is_empty() { - Vec::new() - } else { - string_str - .split(',') - .map(|value| { - value - .trim() - .parse::() - .context("invalid symbol index") - }) - .collect::>>()? - }; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", - alphabet_size, - inferred - ); - anyhow::ensure!( - alphabet_size > 0 || string.is_empty(), - "MinimumExternalMacroDataCompression requires a positive alphabet for non-empty strings.\n\n{usage}" - ); - ( - ser(MinimumExternalMacroDataCompression::new( - alphabet_size, - string, - pointer_cost, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumInternalMacroDataCompression - "MinimumInternalMacroDataCompression" => { - let usage = "Usage: pred create MinimumInternalMacroDataCompression --string \"0,1,0,1\" --pointer-cost 2 [--alphabet-size 2]"; - let string_str = args.string.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumInternalMacroDataCompression requires --string\n\n{usage}") - })?; - let pointer_cost = args.pointer_cost.ok_or_else(|| { - anyhow::anyhow!( - "MinimumInternalMacroDataCompression requires --pointer-cost\n\n{usage}" - ) - })?; - anyhow::ensure!( - pointer_cost > 0, - "--pointer-cost must be a positive integer\n\n{usage}" - ); - - let string: Vec = if string_str.trim().is_empty() { - Vec::new() - } else { - string_str - .split(',') - .map(|value| { - value - .trim() - .parse::() - .context("invalid symbol index") - }) - .collect::>>()? - }; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", - alphabet_size, - inferred - ); - anyhow::ensure!( - alphabet_size > 0 || string.is_empty(), - "MinimumInternalMacroDataCompression requires a positive alphabet for non-empty strings.\n\n{usage}" - ); - ( - ser(MinimumInternalMacroDataCompression::new( - alphabet_size, - string, - pointer_cost, - ))?, - resolved_variant.clone(), - ) - } - - // ClosestVectorProblem - "ClosestVectorProblem" => { - let basis_str = args.basis.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "CVP requires --basis, --target-vec\n\n\ - Usage: pred create CVP --basis \"1,0;0,1\" --target-vec \"0.5,0.5\"" - ) - })?; - let target_str = args - .target_vec - .as_deref() - .ok_or_else(|| anyhow::anyhow!("CVP requires --target-vec (e.g., \"0.5,0.5\")"))?; - let basis: Vec> = basis_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - let target: Vec = util::parse_comma_list(target_str)?; - let n = basis.len(); - let (lo, hi) = match args.bounds.as_deref() { - Some(s) => { - let parts: Vec = util::parse_comma_list(s)?; - if parts.len() != 2 { - bail!("--bounds expects \"lower,upper\" (e.g., \"-10,10\")"); - } - (parts[0], parts[1]) - } - None => (-10, 10), - }; - let bounds = vec![problemreductions::models::algebraic::VarBounds::bounded(lo, hi); n]; - ( - ser(ClosestVectorProblem::new(basis, target, bounds))?, - resolved_variant.clone(), - ) - } - - // ResourceConstrainedScheduling - "ResourceConstrainedScheduling" => { - let usage = "Usage: pred create ResourceConstrainedScheduling --num-processors 3 --resource-bounds \"20\" --resource-requirements \"6;7;7;6;8;6\" --deadline 2"; - let num_processors = args.num_processors.ok_or_else(|| { - anyhow::anyhow!( - "ResourceConstrainedScheduling requires --num-processors\n\n{usage}" - ) - })?; - let bounds_str = args.resource_bounds.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ResourceConstrainedScheduling requires --resource-bounds\n\n{usage}" - ) - })?; - let reqs_str = args.resource_requirements.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ResourceConstrainedScheduling requires --resource-requirements\n\n{usage}" - ) - })?; - let deadline = args.deadline.ok_or_else(|| { - anyhow::anyhow!("ResourceConstrainedScheduling requires --deadline\n\n{usage}") - })?; - - let resource_bounds: Vec = util::parse_comma_list(bounds_str)?; - let resource_requirements: Vec> = reqs_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - - ( - ser(ResourceConstrainedScheduling::new( - num_processors, - resource_bounds, - resource_requirements, - deadline, - ))?, - resolved_variant.clone(), - ) - } - - // MultiprocessorScheduling - "MultiprocessorScheduling" => { - let usage = "Usage: pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10"; - let lengths_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" - ) - })?; - let num_processors = args.num_processors.ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --num-processors\n\n{usage}") - })?; - if num_processors == 0 { - bail!("MultiprocessorScheduling requires --num-processors > 0\n\n{usage}"); - } - let deadline = args.deadline.ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --deadline\n\n{usage}") - })?; - let lengths: Vec = util::parse_comma_list(lengths_str)?; - ( - ser(MultiprocessorScheduling::new( - lengths, - num_processors, - deadline, - ))?, - resolved_variant.clone(), - ) - } - - "ProductionPlanning" => { - let usage = "Usage: pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80"; - let num_periods = args.num_periods.ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") - })?; - let demands = parse_named_u64_list( - args.demands.as_deref(), - "ProductionPlanning", - "--demands", - usage, - )?; - let capacities = parse_named_u64_list( - args.capacities.as_deref(), - "ProductionPlanning", - "--capacities", - usage, - )?; - let setup_costs = parse_named_u64_list( - args.setup_costs.as_deref(), - "ProductionPlanning", - "--setup-costs", - usage, - )?; - let production_costs = parse_named_u64_list( - args.production_costs.as_deref(), - "ProductionPlanning", - "--production-costs", - usage, - )?; - let inventory_costs = parse_named_u64_list( - args.inventory_costs.as_deref(), - "ProductionPlanning", - "--inventory-costs", - usage, - )?; - let cost_bound = args.cost_bound.ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --cost-bound\n\n{usage}") - })? as u64; - - for (flag, len) in [ - ("--demands", demands.len()), - ("--capacities", capacities.len()), - ("--setup-costs", setup_costs.len()), - ("--production-costs", production_costs.len()), - ("--inventory-costs", inventory_costs.len()), - ] { - ensure_named_len(len, num_periods, flag, usage)?; - } - - ( - ser(ProductionPlanning::new( - num_periods, - demands, - capacities, - setup_costs, - production_costs, - inventory_costs, - cost_bound, - ))?, - resolved_variant.clone(), - ) - } - - // PreemptiveScheduling - "PreemptiveScheduling" => { - let usage = "Usage: pred create PreemptiveScheduling --lengths 2,1,3,2,1 --num-processors 2 [--precedences \"0>2,1>3\"]"; - let lengths_str = args - .lengths - .as_deref() - .or(args.sizes.as_deref()) - .ok_or_else(|| { - anyhow::anyhow!( - "PreemptiveScheduling requires --lengths and --num-processors\n\n{usage}" - ) - })?; - let num_processors = args.num_processors.ok_or_else(|| { - anyhow::anyhow!("PreemptiveScheduling requires --num-processors\n\n{usage}") - })?; - anyhow::ensure!( - num_processors > 0, - "PreemptiveScheduling requires --num-processors > 0\n\n{usage}" - ); - let lengths: Vec = util::parse_comma_list(lengths_str)?; - anyhow::ensure!( - lengths.iter().all(|&l| l > 0), - "PreemptiveScheduling: all task lengths must be positive\n\n{usage}" - ); - let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = match args - .precedences - .as_deref() - .or(args.precedence_pairs.as_deref()) - { - Some(s) if !s.is_empty() => s - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid precedence format '{}', expected 'u>v'", - pair.trim() - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?, - _ => vec![], - }; - for &(pred, succ) in &precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); - } - ( - ser(PreemptiveScheduling::new( - lengths, - num_processors, - precedences, - ))?, - resolved_variant.clone(), - ) - } - - // SchedulingToMinimizeWeightedCompletionTime - "SchedulingToMinimizeWeightedCompletionTime" => { - let usage = "Usage: pred create SchedulingToMinimizeWeightedCompletionTime --lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2"; - let lengths_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SchedulingToMinimizeWeightedCompletionTime requires --lengths, --weights, and --num-processors\n\n{usage}" - ) - })?; - let weights_str = args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SchedulingToMinimizeWeightedCompletionTime requires --weights\n\n{usage}" - ) - })?; - let num_processors = args.num_processors.ok_or_else(|| { - anyhow::anyhow!("SchedulingToMinimizeWeightedCompletionTime requires --num-processors\n\n{usage}") - })?; - if num_processors == 0 { - bail!("SchedulingToMinimizeWeightedCompletionTime requires --num-processors > 0\n\n{usage}"); - } - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - ( - ser(SchedulingToMinimizeWeightedCompletionTime::new( - lengths, - weights, - num_processors, - ))?, - resolved_variant.clone(), - ) - } - - "CapacityAssignment" => { - let usage = "Usage: pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; - let capacities_str = args.capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "CapacityAssignment requires --capacities, --cost-matrix, --delay-matrix, and --delay-budget\n\n{usage}" - ) - })?; - let cost_matrix_str = args.cost_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --cost-matrix\n\n{usage}") - })?; - let delay_matrix_str = args.delay_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --delay-matrix\n\n{usage}") - })?; - let delay_budget = args.delay_budget.ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --delay-budget\n\n{usage}") - })?; - - let capacities: Vec = util::parse_comma_list(capacities_str)?; - anyhow::ensure!( - !capacities.is_empty(), - "CapacityAssignment requires at least one capacity value\n\n{usage}" - ); - anyhow::ensure!( - capacities.iter().all(|&capacity| capacity > 0), - "CapacityAssignment capacities must be positive\n\n{usage}" - ); - anyhow::ensure!( - capacities.windows(2).all(|w| w[0] < w[1]), - "CapacityAssignment capacities must be strictly increasing\n\n{usage}" - ); - - let cost = parse_u64_matrix_rows(cost_matrix_str, "cost")?; - let delay = parse_u64_matrix_rows(delay_matrix_str, "delay")?; - anyhow::ensure!( - cost.len() == delay.len(), - "cost matrix row count ({}) must match delay matrix row count ({})\n\n{usage}", - cost.len(), - delay.len() - ); - - for (index, row) in cost.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "cost row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] <= w[1]), - "cost row {} must be non-decreasing\n\n{usage}", - index - ); - } - for (index, row) in delay.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "delay row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] >= w[1]), - "delay row {} must be non-increasing\n\n{usage}", - index - ); - } - - ( - ser(CapacityAssignment::new( - capacities, - cost, - delay, - delay_budget, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumMultiwayCut - "MinimumMultiwayCut" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]" - ) - })?; - let terminals = parse_terminals(args, graph.num_vertices())?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - ( - ser(MinimumMultiwayCut::new(graph, terminals, edge_weights))?, - resolved_variant.clone(), - ) - } - - // MinimumTardinessSequencing - "MinimumTardinessSequencing" => { - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumTardinessSequencing requires --deadlines\n\n\ - Usage: pred create MinimumTardinessSequencing --num-tasks 5 --deadlines 5,5,5,3,3 [--precedences \"0>3,1>3,1>4,2>4\"] [--lengths 3,2,2,1,2]" - ) - })?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?; - - if let Some(lengths_str) = args.lengths.as_deref().or(args.sizes.as_deref()) { - // Arbitrary-length variant (W = i32) - let lengths: Vec = util::parse_comma_list(lengths_str)?; - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - validate_precedence_pairs(&precedences, lengths.len())?; - ( - ser(MinimumTardinessSequencing::::with_lengths( - lengths, - deadlines, - precedences, - ))?, - resolved_variant.clone(), - ) - } else { - // Unit-length variant (W = One) - let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { - anyhow::anyhow!( - "MinimumTardinessSequencing requires --num-tasks (number of tasks) or --lengths\n\n\ - Usage: pred create MinimumTardinessSequencing --num-tasks 5 --deadlines 5,5,5,3,3" - ) - })?; - anyhow::ensure!( - deadlines.len() == num_tasks, - "deadlines length ({}) must equal num_tasks ({})", - deadlines.len(), - num_tasks - ); - validate_precedence_pairs(&precedences, num_tasks)?; - ( - ser(MinimumTardinessSequencing::::new( - num_tasks, - deadlines, - precedences, - ))?, - resolved_variant.clone(), - ) - } - } - - // SchedulingWithIndividualDeadlines - "SchedulingWithIndividualDeadlines" => { - let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and a processor count (--num-processors or --m)\n\n{usage}" - ) - })?; - let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" - ) - })?; - let num_processors = resolve_processor_count_flags( - "SchedulingWithIndividualDeadlines", - usage, - args.num_processors, - args.m, - )? - .ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-processors or --m\n\n{usage}" - ) - })?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences: Vec<(usize, usize)> = match args - .precedences - .as_deref() - .or(args.precedence_pairs.as_deref()) - { - Some(s) if !s.is_empty() => s - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid precedence format '{}', expected 'u>v'", - pair.trim() - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?, - _ => vec![], - }; - anyhow::ensure!( - deadlines.len() == num_tasks, - "deadlines length ({}) must equal num_tasks ({})", - deadlines.len(), - num_tasks - ); - for &(pred, succ) in &precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); - } - ( - ser(SchedulingWithIndividualDeadlines::new( - num_tasks, - num_processors, - deadlines, - precedences, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingToMinimizeTardyTaskWeight - "SequencingToMinimizeTardyTaskWeight" => { - let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeTardyTaskWeight requires --lengths, --weights, and --deadlines\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" - ) - })?; - let weights_str = args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeTardyTaskWeight requires --weights\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" - ) - })?; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeTardyTaskWeight requires --deadlines\n\n\ - Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" - ) - })?; - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - anyhow::ensure!( - lengths.len() == weights.len(), - "lengths length ({}) must equal weights length ({})", - lengths.len(), - weights.len() - ); - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - anyhow::ensure!( - lengths.iter().all(|&l| l > 0), - "task lengths must be positive" - ); - anyhow::ensure!( - weights.iter().all(|&w| w > 0), - "task weights must be positive" - ); - ( - ser(SequencingToMinimizeTardyTaskWeight::new( - lengths, weights, deadlines, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingWithDeadlinesAndSetUpTimes - "SequencingWithDeadlinesAndSetUpTimes" => { - let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithDeadlinesAndSetUpTimes requires --lengths, --deadlines, --compilers, and --setup-times\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" - ) - })?; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithDeadlinesAndSetUpTimes requires --deadlines\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" - ) - })?; - let compilers_str = args.compilers.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithDeadlinesAndSetUpTimes requires --compilers\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" - ) - })?; - let setup_times_str = args.setup_times.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithDeadlinesAndSetUpTimes requires --setup-times\n\n\ - Usage: pred create SequencingWithDeadlinesAndSetUpTimes --lengths 2,3,1,2,2 --deadlines 4,11,3,16,7 --compilers 0,1,0,1,0 --setup-times 1,2" - ) - })?; - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let compilers: Vec = util::parse_comma_list(compilers_str)?; - let setup_times: Vec = util::parse_comma_list(setup_times_str)?; - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - anyhow::ensure!( - lengths.len() == compilers.len(), - "lengths length ({}) must equal compilers length ({})", - lengths.len(), - compilers.len() - ); - anyhow::ensure!( - lengths.iter().all(|&l| l > 0), - "task lengths must be positive" - ); - let num_compilers = setup_times.len(); - for &c in &compilers { - anyhow::ensure!( - c < num_compilers, - "compiler index {c} is out of range for setup_times of length {num_compilers}" - ); - } - ( - ser(SequencingWithDeadlinesAndSetUpTimes::new( - lengths, - deadlines, - compilers, - setup_times, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingToMinimizeWeightedCompletionTime - "SequencingToMinimizeWeightedCompletionTime" => { - let lengths_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedCompletionTime requires --lengths and --weights\n\n\ - Usage: pred create SequencingToMinimizeWeightedCompletionTime --lengths 2,1,3,1,2 --weights 3,5,1,4,2 [--precedences \"0>2,1>4\"]" - ) - })?; - let weights_str = args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedCompletionTime requires --weights\n\n\ - Usage: pred create SequencingToMinimizeWeightedCompletionTime --lengths 2,1,3,1,2 --weights 3,5,1,4,2" - ) - })?; - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - anyhow::ensure!( - lengths.len() == weights.len(), - "lengths length ({}) must equal weights length ({})", - lengths.len(), - weights.len() - ); - anyhow::ensure!( - lengths.iter().all(|&length| length > 0), - "task lengths must be positive" - ); - let num_tasks = lengths.len(); - let precedences: Vec<(usize, usize)> = match args - .precedences - .as_deref() - .or(args.precedence_pairs.as_deref()) - { - Some(s) if !s.is_empty() => s - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid precedence format '{}', expected 'u>v'", - pair.trim() - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?, - _ => vec![], - }; - for &(pred, succ) in &precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); - } - ( - ser(SequencingToMinimizeWeightedCompletionTime::new( - lengths, - weights, - precedences, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingToMinimizeWeightedTardiness - "SequencingToMinimizeWeightedTardiness" => { - let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let weights_str = args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - anyhow::ensure!(bound >= 0, "--bound must be non-negative"); - - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - - anyhow::ensure!( - lengths.len() == weights.len(), - "lengths length ({}) must equal weights length ({})", - lengths.len(), - weights.len() - ); - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - - ( - ser(SequencingToMinimizeWeightedTardiness::new( - lengths, - weights, - deadlines, - bound as u64, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingToMinimizeMaximumCumulativeCost - "SequencingToMinimizeMaximumCumulativeCost" => { - let costs_str = args.costs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ - Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" - ) - })?; - let costs: Vec = util::parse_comma_list(costs_str)?; - let precedences = parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?; - validate_precedence_pairs(&precedences, costs.len())?; - ( - ser(SequencingToMinimizeMaximumCumulativeCost::new( - costs, - precedences, - ))?, - resolved_variant.clone(), - ) - } - - // SequencingWithinIntervals - "SequencingWithinIntervals" => { - let usage = "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; - let rt_str = args.release_times.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") - })?; - let dl_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") - })?; - let len_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --lengths\n\n{usage}") - })?; - let release_times: Vec = util::parse_comma_list(rt_str)?; - let deadlines: Vec = util::parse_comma_list(dl_str)?; - let lengths: Vec = util::parse_comma_list(len_str)?; - validate_sequencing_within_intervals_inputs( - &release_times, - &deadlines, - &lengths, - usage, - )?; - ( - ser(SequencingWithinIntervals::new( - release_times, - deadlines, - lengths, - ))?, - resolved_variant.clone(), - ) - } - - // OptimalLinearArrangement — graph only (optimization) - "OptimalLinearArrangement" => { - let usage = "Usage: pred create OptimalLinearArrangement --graph 0-1,1-2,2-3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - ( - ser(OptimalLinearArrangement::new(graph))?, - resolved_variant.clone(), - ) - } - - // RootedTreeArrangement — graph + bound - "RootedTreeArrangement" => { - let usage = - "Usage: pred create RootedTreeArrangement --graph 0-1,0-2,1-2,2-3,3-4 --bound 7"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "RootedTreeArrangement requires --bound (upper bound K on total tree stretch)\n\n{usage}" - ) - })?; - let bound = parse_nonnegative_usize_bound(bound_raw, "RootedTreeArrangement", usage)?; - ( - ser(RootedTreeArrangement::new(graph, bound))?, - resolved_variant.clone(), - ) - } - - // FlowShopScheduling - "FlowShopScheduling" => { - let task_str = args.task_lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "FlowShopScheduling requires --task-lengths and --deadline\n\n\ - Usage: pred create FlowShopScheduling --task-lengths \"3,4,2;2,3,5;4,1,3\" --deadline 25 --num-processors 3" - ) - })?; - let deadline = args.deadline.ok_or_else(|| { - anyhow::anyhow!( - "FlowShopScheduling requires --deadline\n\n\ - Usage: pred create FlowShopScheduling --task-lengths \"3,4,2;2,3,5;4,1,3\" --deadline 25 --num-processors 3" - ) - })?; - let task_lengths: Vec> = task_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - let num_processors = resolve_processor_count_flags( - "FlowShopScheduling", - "Usage: pred create FlowShopScheduling --task-lengths \"3,4,2;2,3,5;4,1,3\" --deadline 25 --num-processors 3", - args.num_processors, - args.m, - )? - .or_else(|| task_lengths.first().map(Vec::len)) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty task list; use --num-processors" - ) - })?; - for (j, row) in task_lengths.iter().enumerate() { - if row.len() != num_processors { - bail!( - "task_lengths row {} has {} entries, expected {} (num_processors)", - j, - row.len(), - num_processors - ); - } - } - ( - ser(FlowShopScheduling::new( - num_processors, - task_lengths, - deadline, - ))?, - resolved_variant.clone(), - ) - } - - // JobShopScheduling - "JobShopScheduling" => { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let job_tasks = args - .job_tasks - .as_deref() - .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; - let jobs = parse_job_shop_jobs(job_tasks)?; - let inferred_processors = jobs - .iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1); - let num_processors = resolve_processor_count_flags( - "JobShopScheduling", - usage, - args.num_processors, - args.m, - )? - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - anyhow::ensure!( - num_processors > 0, - "JobShopScheduling requires --num-processors > 0\n\n{usage}" - ); - for (job_index, job) in jobs.iter().enumerate() { - for (task_index, &(processor, _)) in job.iter().enumerate() { - anyhow::ensure!( - processor < num_processors, - "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" - ); - } - for (task_index, pair) in job.windows(2).enumerate() { - anyhow::ensure!( - pair[0].0 != pair[1].0, - "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", - task_index + 1 - ); - } - } - ( - ser(JobShopScheduling::new(num_processors, jobs))?, - resolved_variant.clone(), - ) - } - - // OpenShopScheduling - "OpenShopScheduling" => { - let task_str = args.task_lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "OpenShopScheduling requires --task-lengths and --num-processors\n\n\ - Usage: pred create OpenShopScheduling --task-lengths \"1,2;2,1\" --num-processors 2" - ) - })?; - let task_lengths: Vec> = task_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - let num_machines = resolve_processor_count_flags( - "OpenShopScheduling", - "Usage: pred create OpenShopScheduling --task-lengths \"1,2;2,1\" --num-processors 2", - args.num_processors, - args.m, - )? - .or_else(|| task_lengths.first().map(Vec::len)) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty task list; use --num-processors" - ) - })?; - for (j, row) in task_lengths.iter().enumerate() { - if row.len() != num_machines { - bail!( - "task_lengths row {} has {} entries, expected {} (num_machines)", - j, - row.len(), - num_machines - ); - } - } - ( - ser(OpenShopScheduling::new(num_machines, task_lengths))?, - resolved_variant.clone(), - ) - } - - // StaffScheduling - "StaffScheduling" => { - let usage = "Usage: pred create StaffScheduling --schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5"; - let schedules = parse_schedules(args, usage)?; - let requirements = parse_requirements(args, usage)?; - let num_workers = args.num_workers.ok_or_else(|| { - anyhow::anyhow!("StaffScheduling requires --num-workers\n\n{usage}") - })?; - let shifts_per_schedule = args - .k - .ok_or_else(|| anyhow::anyhow!("StaffScheduling requires --k\n\n{usage}"))?; - validate_staff_scheduling_args( - &schedules, - &requirements, - shifts_per_schedule, - num_workers, - usage, - )?; - - ( - ser(problemreductions::models::misc::StaffScheduling::new( - shifts_per_schedule, - schedules, - requirements, - num_workers, - ))?, - resolved_variant.clone(), - ) - } - - // TimetableDesign - "TimetableDesign" => { - let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; - let num_periods = args.num_periods.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") - })?; - let num_craftsmen = args.num_craftsmen.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") - })?; - let num_tasks = args.num_tasks.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") - })?; - let craftsman_avail = - parse_named_bool_rows(args.craftsman_avail.as_deref(), "--craftsman-avail", usage)?; - let task_avail = - parse_named_bool_rows(args.task_avail.as_deref(), "--task-avail", usage)?; - let requirements = parse_timetable_requirements(args.requirements.as_deref(), usage)?; - validate_timetable_design_args( - num_periods, - num_craftsmen, - num_tasks, - &craftsman_avail, - &task_avail, - &requirements, - usage, - )?; - - ( - ser(TimetableDesign::new( - num_periods, - num_craftsmen, - num_tasks, - craftsman_avail, - task_avail, - requirements, - ))?, - resolved_variant.clone(), - ) - } - - // DirectedTwoCommodityIntegralFlow - "DirectedTwoCommodityIntegralFlow" => { - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "DirectedTwoCommodityIntegralFlow requires --arcs\n\n\ - Usage: pred create DirectedTwoCommodityIntegralFlow \ - --arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" \ - --capacities 1,1,1,1,1,1,1,1 \ - --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 \ - --requirement-1 1 --requirement-2 1" - ) - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let capacities: Vec = if let Some(ref s) = args.capacities { - util::parse_comma_list(s)? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "capacities length ({}) must match number of arcs ({num_arcs})", - capacities.len() - ); - let n = graph.num_vertices(); - let source_1 = args.source_1.ok_or_else(|| { - anyhow::anyhow!("DirectedTwoCommodityIntegralFlow requires --source-1") - })?; - let sink_1 = args.sink_1.ok_or_else(|| { - anyhow::anyhow!("DirectedTwoCommodityIntegralFlow requires --sink-1") - })?; - let source_2 = args.source_2.ok_or_else(|| { - anyhow::anyhow!("DirectedTwoCommodityIntegralFlow requires --source-2") - })?; - let sink_2 = args.sink_2.ok_or_else(|| { - anyhow::anyhow!("DirectedTwoCommodityIntegralFlow requires --sink-2") - })?; - for (name, idx) in [ - ("source_1", source_1), - ("sink_1", sink_1), - ("source_2", source_2), - ("sink_2", sink_2), - ] { - anyhow::ensure!(idx < n, "{name} ({idx}) >= num_vertices ({n})"); - } - let requirement_1 = args.requirement_1.unwrap_or(1); - let requirement_2 = args.requirement_2.unwrap_or(1); - ( - ser(DirectedTwoCommodityIntegralFlow::new( - graph, - capacities, - source_1, - sink_1, - source_2, - sink_2, - requirement_1, - requirement_2, - ))?, - resolved_variant.clone(), - ) - } - - // IntegralFlowHomologousArcs - "IntegralFlowHomologousArcs" => { - let usage = "Usage: pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\""; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(ref s) = args.capacities { - s.split(',') - .map(|token| { - let trimmed = token.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage - ); - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - anyhow::ensure!( - fits, - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage - ); - } - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") - })?; - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - let homologous_pairs = - parse_homologous_pairs(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - for &(a, b) in &homologous_pairs { - anyhow::ensure!( - a < num_arcs && b < num_arcs, - "homologous pair ({}, {}) references arc >= num_arcs ({})\n\n{}", - a, - b, - num_arcs, - usage - ); - } - ( - ser(IntegralFlowHomologousArcs::new( - graph, - capacities, - source, - sink, - requirement, - homologous_pairs, - ))?, - resolved_variant.clone(), - ) - } - - // PathConstrainedNetworkFlow - "PathConstrainedNetworkFlow" => { - let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(ref s) = args.capacities { - util::parse_comma_list(s)? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "capacities length ({}) must match number of arcs ({num_arcs})", - capacities.len() - ); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") - })?; - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") - })?; - let paths = parse_prescribed_paths(args, num_arcs, usage)?; - ( - ser(PathConstrainedNetworkFlow::new( - graph, - capacities, - source, - sink, - paths, - requirement, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumFeedbackArcSet - "MinimumFeedbackArcSet" => { - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumFeedbackArcSet requires --arcs\n\n\ - Usage: pred create FAS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" - ) - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let weights = parse_arc_weights(args, num_arcs)?; - ( - ser(MinimumFeedbackArcSet::new(graph, weights))?, - resolved_variant.clone(), - ) - } - - // DegreeConstrainedSpanningTree - "DegreeConstrainedSpanningTree" => { - let usage = "Usage: pred create DegreeConstrainedSpanningTree --graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --k 2"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let max_degree = args.k.ok_or_else(|| { - anyhow::anyhow!( - "DegreeConstrainedSpanningTree requires --k (maximum vertex degree)\n\n{usage}" - ) - })?; - anyhow::ensure!( - max_degree >= 1, - "DegreeConstrainedSpanningTree requires --k >= 1, got {}", - max_degree - ); - ( - ser( - problemreductions::models::graph::DegreeConstrainedSpanningTree::new( - graph, max_degree, - ), - )?, - resolved_variant.clone(), - ) - } - - // DirectedHamiltonianPath - "DirectedHamiltonianPath" => { - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "DirectedHamiltonianPath requires --arcs\n\n\ - Usage: pred create DirectedHamiltonianPath --arcs \"0>1,1>2,2>3\" [--num-vertices N]" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - ( - ser(DirectedHamiltonianPath::new(graph))?, - resolved_variant.clone(), - ) - } - - // Kernel - "Kernel" => { - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Kernel requires --arcs\n\n\ - Usage: pred create Kernel --arcs \"0>1,1>2,2>0\" [--num-vertices N]" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - (ser(Kernel::new(graph))?, resolved_variant.clone()) - } - - // AcyclicPartition - "AcyclicPartition" => { - let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-weights 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let vertex_weights = parse_vertex_weights(args, graph.num_vertices())?; - let arc_costs = parse_arc_costs(args, num_arcs)?; - let weight_bound = args.weight_bound.ok_or_else(|| { - anyhow::anyhow!("AcyclicPartition requires --weight-bound\n\n{usage}") - })?; - let cost_bound = args.cost_bound.ok_or_else(|| { - anyhow::anyhow!("AcyclicPartition requires --cost-bound\n\n{usage}") - })?; - if vertex_weights.iter().any(|&weight| weight <= 0) { - bail!("AcyclicPartition --weights must be positive (Z+)"); - } - if arc_costs.iter().any(|&cost| cost <= 0) { - bail!("AcyclicPartition --arc-weights must be positive (Z+)"); - } - if weight_bound <= 0 { - bail!("AcyclicPartition --weight-bound must be positive (Z+)"); - } - if cost_bound <= 0 { - bail!("AcyclicPartition --cost-bound must be positive (Z+)"); - } - ( - ser(AcyclicPartition::new( - graph, - vertex_weights, - arc_costs, - weight_bound, - cost_bound, - ))?, - resolved_variant.clone(), - ) - } - - // MinMaxMulticenter (vertex p-center) - "MinMaxMulticenter" => { - let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2"; - let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let vertex_weights = parse_vertex_weights(args, n)?; - let edge_lengths = parse_edge_weights(args, graph.num_edges())?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!( - "MinMaxMulticenter requires --k (number of centers)\n\n\ - Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2" - ) - })?; - if vertex_weights.iter().any(|&weight| weight < 0) { - bail!("MinMaxMulticenter --weights must be non-negative"); - } - if edge_lengths.iter().any(|&length| length < 0) { - bail!("MinMaxMulticenter --edge-weights must be non-negative"); - } - ( - ser(MinMaxMulticenter::new( - graph, - vertex_weights, - edge_lengths, - k, - ))?, - resolved_variant.clone(), - ) - } - - // StrongConnectivityAugmentation - "StrongConnectivityAugmentation" => { - let usage = "Usage: pred create StrongConnectivityAugmentation --arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1 [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "StrongConnectivityAugmentation requires --arcs\n\n\ - {usage}" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let candidate_arcs = parse_candidate_arcs(args, graph.num_vertices())?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "StrongConnectivityAugmentation requires --bound\n\n\ - {usage}" - ) - })? as i32; - ( - ser( - StrongConnectivityAugmentation::try_new(graph, candidate_arcs, bound) - .map_err(|e| anyhow::anyhow!(e))?, - )?, - resolved_variant.clone(), - ) - } - - // MinimumGeometricConnectedDominatingSet - "MinimumGeometricConnectedDominatingSet" => { - let usage = "Usage: pred create MinimumGeometricConnectedDominatingSet --positions \"0,0;3,0;6,0\" --radius 3.5"; - let positions = parse_float_positions(args).map_err(|_| { - anyhow::anyhow!( - "MinimumGeometricConnectedDominatingSet requires --positions\n\n\ - {usage}" - ) - })?; - let radius = args.radius.ok_or_else(|| { - anyhow::anyhow!( - "MinimumGeometricConnectedDominatingSet requires --radius\n\n\ - {usage}" - ) - })?; - ( - ser( - MinimumGeometricConnectedDominatingSet::try_new(positions, radius) - .map_err(|e| anyhow::anyhow!(e))?, - )?, - resolved_variant.clone(), - ) - } - - // MinimumEdgeCostFlow - "MinimumEdgeCostFlow" => { - let usage = "Usage: pred create MinimumEdgeCostFlow --arcs \"0>1,0>2,0>3,1>4,2>4,3>4\" --edge-weights 3,1,2,0,0,0 --capacities 2,2,2,2,2,2 --source 0 --sink 4 --requirement 3"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("MinimumEdgeCostFlow requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let prices: Vec = if let Some(ref s) = args.edge_weights { - util::parse_comma_list(s)? - } else { - bail!("MinimumEdgeCostFlow requires --edge-weights (prices)\n\n{usage}"); - }; - anyhow::ensure!( - prices.len() == num_arcs, - "--edge-weights length ({}) must match number of arcs ({num_arcs})", - prices.len() - ); - let capacities: Vec = if let Some(ref s) = args.capacities { - util::parse_comma_list(s)? - } else { - bail!("MinimumEdgeCostFlow requires --capacities\n\n{usage}"); - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "--capacities length ({}) must match number of arcs ({num_arcs})", - capacities.len() - ); - let n = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("MinimumEdgeCostFlow requires --source\n\n{usage}") - })?; - let sink = args - .sink - .ok_or_else(|| anyhow::anyhow!("MinimumEdgeCostFlow requires --sink\n\n{usage}"))?; - anyhow::ensure!(source < n, "--source ({source}) >= num_vertices ({n})"); - anyhow::ensure!(sink < n, "--sink ({sink}) >= num_vertices ({n})"); - anyhow::ensure!(source != sink, "--source and --sink must be distinct"); - let requirement = args.requirement.unwrap_or(1) as i64; - ( - ser(problemreductions::models::graph::MinimumEdgeCostFlow::new( - graph, - prices, - capacities, - source, - sink, - requirement, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumDummyActivitiesPert - "MinimumDummyActivitiesPert" => { - let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumDummyActivitiesPert requires --arcs\n\n\ - {usage}" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - ( - ser(MinimumDummyActivitiesPert::try_new(graph).map_err(|e| anyhow::anyhow!(e))?)?, - resolved_variant.clone(), - ) - } - - // FeasibleRegisterAssignment - "FeasibleRegisterAssignment" => { - let usage = "Usage: pred create FeasibleRegisterAssignment --arcs \"0>1,0>2,1>3\" --assignment 0,1,0,0 --k 2 [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "FeasibleRegisterAssignment requires --arcs, --assignment, and --k\n\n\ - {usage}" - ) - })?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!( - "FeasibleRegisterAssignment requires --k (number of registers)\n\n\ - {usage}" - ) - })?; - let assignment_str = args.assignment.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "FeasibleRegisterAssignment requires --assignment\n\n\ - {usage}" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let n = graph.num_vertices(); - let arcs = graph.arcs(); - let assignment: Vec = assignment_str - .split(',') - .map(|s| { - s.trim() - .parse::() - .with_context(|| format!("Invalid assignment value: {s}")) - }) - .collect::>()?; - if assignment.len() != n { - bail!( - "Assignment length {} does not match vertex count {}\n\n{usage}", - assignment.len(), - n - ); - } - ( - ser(FeasibleRegisterAssignment::new(n, arcs, k, assignment))?, - resolved_variant.clone(), - ) - } - - // RegisterSufficiency - "RegisterSufficiency" => { - let usage = "Usage: pred create RegisterSufficiency --arcs \"2>0,2>1,3>1,4>2,4>3,5>0,6>4,6>5\" --bound 3 [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "RegisterSufficiency requires --arcs and --bound\n\n\ - {usage}" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "RegisterSufficiency requires --bound\n\n\ - {usage}" - ) - })?; - if bound < 0 { - bail!("RegisterSufficiency --bound must be non-negative\n\n{usage}"); - } - let bound = bound as usize; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let n = graph.num_vertices(); - let arcs = graph.arcs(); - ( - ser(RegisterSufficiency::new(n, arcs, bound))?, - resolved_variant.clone(), - ) - } - - // MinimumCodeGenerationOneRegister - "MinimumCodeGenerationOneRegister" => { - let usage = "Usage: pred create MinimumCodeGenerationOneRegister --arcs \"0>1,0>2,1>3,1>4,2>3,2>5,3>5,3>6\" [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumCodeGenerationOneRegister requires --arcs\n\n\ - {usage}" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let n = graph.num_vertices(); - let arcs = graph.arcs(); - // Compute num_leaves: vertices with out-degree 0 - let mut out_degree = vec![0usize; n]; - for &(parent, _child) in &arcs { - out_degree[parent] += 1; - } - let num_leaves = out_degree.iter().filter(|&&d| d == 0).count(); - ( - ser(MinimumCodeGenerationOneRegister::new(n, arcs, num_leaves))?, - resolved_variant.clone(), - ) - } - - // MinimumCodeGenerationUnlimitedRegisters - "MinimumCodeGenerationUnlimitedRegisters" => { - let usage = "Usage: pred create MinimumCodeGenerationUnlimitedRegisters --left-arcs \"1>3,2>3,0>1\" --right-arcs \"1>4,2>4,0>2\" [--num-vertices N]"; - let left_str = args.left_arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumCodeGenerationUnlimitedRegisters requires --left-arcs\n\n\ - {usage}" - ) - })?; - let right_str = args.right_arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumCodeGenerationUnlimitedRegisters requires --right-arcs\n\n\ - {usage}" - ) - })?; - let (left_graph, _) = parse_directed_graph(left_str, args.num_vertices)?; - let (right_graph, _) = parse_directed_graph(right_str, args.num_vertices)?; - let n = if let Some(nv) = args.num_vertices { - nv - } else { - left_graph.num_vertices().max(right_graph.num_vertices()) - }; - let left_arcs = left_graph.arcs(); - let right_arcs = right_graph.arcs(); - ( - ser(MinimumCodeGenerationUnlimitedRegisters::new( - n, left_arcs, right_arcs, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumCodeGenerationParallelAssignments - "MinimumCodeGenerationParallelAssignments" => { - let usage = "Usage: pred create MinimumCodeGenerationParallelAssignments --num-variables 4 --assignments \"0:1,2;1:0;2:3;3:1,2\""; - let nv = args.num_variables.ok_or_else(|| { - anyhow::anyhow!( - "MinimumCodeGenerationParallelAssignments requires --num-variables and --assignments\n\n\ - {usage}" - ) - })?; - let assign_str = args.assignments.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumCodeGenerationParallelAssignments requires --assignments\n\n\ - {usage}" - ) - })?; - let assignments: Vec<(usize, Vec)> = assign_str - .split(';') - .map(|entry| { - let parts: Vec<&str> = entry.split(':').collect(); - anyhow::ensure!( - parts.len() == 2, - "each assignment must be 'target:read1,read2,...'; got '{entry}'" - ); - let target: usize = parts[0] - .trim() - .parse() - .context("invalid target variable index")?; - let reads: Vec = if parts[1].trim().is_empty() { - Vec::new() - } else { - parts[1] - .split(',') - .map(|s| { - s.trim() - .parse::() - .context("invalid read variable index") - }) - .collect::>>()? - }; - Ok((target, reads)) - }) - .collect::>>()?; - ( - ser(MinimumCodeGenerationParallelAssignments::new( - nv, - assignments, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumRegisterSufficiencyForLoops - "MinimumRegisterSufficiencyForLoops" => { - let usage = "Usage: pred create MinimumRegisterSufficiencyForLoops --loop-length 6 --loop-variables \"0,3;2,3;4,3\""; - let loop_length = args.loop_length.ok_or_else(|| { - anyhow::anyhow!( - "MinimumRegisterSufficiencyForLoops requires --loop-length and --loop-variables\n\n\ - {usage}" - ) - })?; - let vars_str = args.loop_variables.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumRegisterSufficiencyForLoops requires --loop-variables\n\n\ - {usage}" - ) - })?; - let variables: Vec<(usize, usize)> = vars_str - .split(';') - .map(|pair| { - let parts: Vec<&str> = pair.split(',').collect(); - if parts.len() != 2 { - bail!("Each variable must be start,duration (got '{pair}')\n\n{usage}"); - } - let start: usize = parts[0] - .trim() - .parse() - .context(format!("Invalid start_time in '{pair}'\n\n{usage}"))?; - let dur: usize = parts[1] - .trim() - .parse() - .context(format!("Invalid duration in '{pair}'\n\n{usage}"))?; - Ok((start, dur)) - }) - .collect::>>()?; - ( - ser(MinimumRegisterSufficiencyForLoops::new( - loop_length, - variables, - ))?, - resolved_variant.clone(), - ) - } - - // MinimumFaultDetectionTestSet - "MinimumFaultDetectionTestSet" => { - let usage = "Usage: pred create MinimumFaultDetectionTestSet --arcs \"0>2,0>3,1>3,1>4,2>5,3>5,3>6,4>6\" --inputs 0,1 --outputs 5,6 [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumFaultDetectionTestSet requires --arcs, --inputs, and --outputs\n\n\ - {usage}" - ) - })?; - let inputs_str = args.inputs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumFaultDetectionTestSet requires --inputs\n\n\ - {usage}" - ) - })?; - let outputs_str = args.outputs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumFaultDetectionTestSet requires --outputs\n\n\ - {usage}" - ) - })?; - let (graph, _num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let n = graph.num_vertices(); - let arcs = graph.arcs(); - let inputs: Vec = inputs_str - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid input vertex '{}': {}", s.trim(), e)) - }) - .collect::>()?; - let outputs: Vec = outputs_str - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid output vertex '{}': {}", s.trim(), e)) - }) - .collect::>()?; - ( - ser(MinimumFaultDetectionTestSet::new(n, arcs, inputs, outputs))?, - resolved_variant.clone(), - ) - } - - // MinimumWeightAndOrGraph - "MinimumWeightAndOrGraph" => { - let usage = "Usage: pred create MinimumWeightAndOrGraph --arcs \"0>1,0>2,1>3,1>4,2>5,2>6\" --source 0 --gate-types \"AND,OR,OR,L,L,L,L\" --weights 1,2,3,1,4,2 [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightAndOrGraph requires --arcs, --source, --gate-types, and --weights\n\n\ - {usage}" - ) - })?; - let source = args.source.ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightAndOrGraph requires --source\n\n\ - {usage}" - ) - })?; - let gate_types_str = args.gate_types.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumWeightAndOrGraph requires --gate-types (e.g., \"AND,OR,OR,L,L,L,L\")\n\n\ - {usage}" - ) - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let n = graph.num_vertices(); - let arcs = graph.arcs(); - let arc_weights = parse_arc_weights(args, num_arcs)?; - let gate_types: Vec> = gate_types_str - .split(',') - .map(|s| match s.trim() { - "AND" | "and" => Ok(Some(true)), - "OR" | "or" => Ok(Some(false)), - "L" | "l" | "LEAF" | "leaf" => Ok(None), - other => Err(anyhow::anyhow!( - "Invalid gate type '{}': expected AND, OR, or L (leaf)\n\n{usage}", - other - )), - }) - .collect::>()?; - if gate_types.len() != n { - bail!( - "Gate types length {} does not match vertex count {}\n\n{usage}", - gate_types.len(), - n - ); - } - ( - ser(MinimumWeightAndOrGraph::new( - n, - arcs, - source, - gate_types, - arc_weights, - ))?, - resolved_variant.clone(), - ) - } - - // MixedChinesePostman - "MixedChinesePostman" => { - let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; - let graph = parse_mixed_graph(args, usage)?; - let arc_costs = parse_arc_costs(args, graph.num_arcs())?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - if arc_costs.iter().any(|&cost| cost < 0) { - bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); - } - if edge_weights.iter().any(|&weight| weight < 0) { - bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); - } - if resolved_variant.get("weight").map(|w| w.as_str()) == Some("One") - && (arc_costs.iter().any(|&cost| cost != 1) - || edge_weights.iter().any(|&weight| weight != 1)) - { - bail!( - "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ - Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." - ); - } - ( - ser(MixedChinesePostman::new(graph, arc_costs, edge_weights))?, - resolved_variant.clone(), - ) - } - - // MinimumSumMulticenter (p-median) - "MinimumSumMulticenter" => { - let (graph, n) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MinimumSumMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2" - ) - })?; - let vertex_weights = parse_vertex_weights(args, n)?; - let edge_lengths = parse_edge_weights(args, graph.num_edges())?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!( - "MinimumSumMulticenter requires --k (number of centers)\n\n\ - Usage: pred create MinimumSumMulticenter --graph 0-1,1-2,2-3 --k 2" - ) - })?; - ( - ser(MinimumSumMulticenter::new( - graph, - vertex_weights, - edge_lengths, - k, - ))?, - resolved_variant.clone(), - ) - } - - // SubgraphIsomorphism - "SubgraphIsomorphism" => { - let (host_graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create SubgraphIsomorphism --graph 0-1,1-2,2-0 --pattern 0-1" - ) - })?; - let pattern_str = args.pattern.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SubgraphIsomorphism requires --pattern (pattern graph edges)\n\n\ - Usage: pred create SubgraphIsomorphism --graph 0-1,1-2,2-0 --pattern 0-1" - ) - })?; - let pattern_edges: Vec<(usize, usize)> = pattern_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('-').collect(); - if parts.len() != 2 { - bail!("Invalid edge '{}': expected format u-v", pair.trim()); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - if u == v { - bail!( - "Invalid edge '{}': self-loops are not allowed in simple graphs", - pair.trim() - ); - } - Ok((u, v)) - }) - .collect::>>()?; - let pattern_nv = pattern_edges - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let pattern_graph = SimpleGraph::new(pattern_nv, pattern_edges); - ( - ser(SubgraphIsomorphism::new(host_graph, pattern_graph))?, - resolved_variant.clone(), - ) - } - - // MonochromaticTriangle - "MonochromaticTriangle" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create MonochromaticTriangle --graph 0-1,0-2,0-3,1-2,1-3,2-3" - ) - })?; - ( - ser(MonochromaticTriangle::new(graph))?, - resolved_variant.clone(), - ) - } - - // PartitionIntoTriangles - "PartitionIntoTriangles" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create PartitionIntoTriangles --graph 0-1,1-2,0-2" - ) - })?; - anyhow::ensure!( - graph.num_vertices() % 3 == 0, - "PartitionIntoTriangles requires vertex count divisible by 3, got {}", - graph.num_vertices() - ); - ( - ser(PartitionIntoTriangles::new(graph))?, - resolved_variant.clone(), - ) - } - - // PartitionIntoCliques - "PartitionIntoCliques" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create PartitionIntoCliques --graph 0-1,0-2,1-2,3-4,3-5,4-5 --k 3" - ) - })?; - let num_cliques = args.k.ok_or_else(|| { - anyhow::anyhow!( - "PartitionIntoCliques requires --k (maximum number of clique groups)\n\n\ - Usage: pred create PartitionIntoCliques --graph 0-1,0-2,1-2,3-4,3-5,4-5 --k 3" - ) - })?; - anyhow::ensure!( - num_cliques >= 1, - "PartitionIntoCliques requires --k >= 1, got {}", - num_cliques - ); - anyhow::ensure!( - num_cliques <= graph.num_vertices(), - "PartitionIntoCliques requires --k <= num_vertices ({}), got {}", - graph.num_vertices(), - num_cliques - ); - ( - ser(problemreductions::models::graph::PartitionIntoCliques::new( - graph, - num_cliques, - ))?, - resolved_variant.clone(), - ) - } - - // PartitionIntoPerfectMatchings - "PartitionIntoPerfectMatchings" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create PartitionIntoPerfectMatchings --graph 0-1,2-3,0-2,1-3 --k 2" - ) - })?; - let num_matchings = args.k.ok_or_else(|| { - anyhow::anyhow!( - "PartitionIntoPerfectMatchings requires --k (maximum number of matching groups)\n\n\ - Usage: pred create PartitionIntoPerfectMatchings --graph 0-1,2-3,0-2,1-3 --k 2" - ) - })?; - anyhow::ensure!( - num_matchings >= 1, - "PartitionIntoPerfectMatchings requires --k >= 1, got {}", - num_matchings - ); - anyhow::ensure!( - num_matchings <= graph.num_vertices(), - "PartitionIntoPerfectMatchings requires --k <= num_vertices ({}), got {}", - graph.num_vertices(), - num_matchings - ); - ( - ser( - problemreductions::models::graph::PartitionIntoPerfectMatchings::new( - graph, - num_matchings, - ), - )?, - resolved_variant.clone(), - ) - } - - // PartitionIntoForests - "PartitionIntoForests" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create PartitionIntoForests --graph 0-1,1-2,2-0,3-4,4-5,5-3 --k 2" - ) - })?; - let num_forests = args.k.ok_or_else(|| { - anyhow::anyhow!( - "PartitionIntoForests requires --k (number of forest classes)\n\n\ - Usage: pred create PartitionIntoForests --graph 0-1,1-2,2-0,3-4,4-5,5-3 --k 2" - ) - })?; - anyhow::ensure!( - num_forests >= 1, - "PartitionIntoForests requires --k >= 1, got {}", - num_forests - ); - ( - ser(problemreductions::models::graph::PartitionIntoForests::new( - graph, - num_forests, - ))?, - resolved_variant.clone(), - ) - } - - // ShortestCommonSupersequence - "ShortestCommonSupersequence" => { - let usage = "Usage: pred create SCS --strings \"0,1,2;1,2,0\""; - let strings_str = args.strings.as_deref().ok_or_else(|| { - anyhow::anyhow!("ShortestCommonSupersequence requires --strings\n\n{usage}") - })?; - let strings: Vec> = strings_str - .split(';') - .map(|s| { - let trimmed = s.trim(); - if trimmed.is_empty() { - return Ok(Vec::new()); - } - trimmed - .split(',') - .map(|v| { - v.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid alphabet index: {}", e)) - }) - .collect::>>() - }) - .collect::>>()?; - let inferred = strings - .iter() - .flat_map(|s| s.iter()) - .copied() - .max() - .map(|m| m + 1) - .unwrap_or(0); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - if alphabet_size < inferred { - anyhow::bail!( - "--alphabet-size {} is smaller than the largest symbol + 1 ({}) in the strings", - alphabet_size, - inferred - ); - } - ( - ser(ShortestCommonSupersequence::new(alphabet_size, strings))?, - resolved_variant.clone(), - ) - } - - // MinimumFeedbackVertexSet - "MinimumFeedbackVertexSet" => { - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MinimumFeedbackVertexSet requires --arcs\n\n\ - Usage: pred create FVS --arcs \"0>1,1>2,2>0\" [--weights 1,1,1] [--num-vertices N]" - ) - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let num_v = graph.num_vertices(); - let weights = parse_vertex_weights(args, num_v)?; - ( - ser(MinimumFeedbackVertexSet::new(graph, weights))?, - resolved_variant.clone(), - ) - } - - "ConjunctiveQueryFoldability" => { - bail!( - "ConjunctiveQueryFoldability has complex nested input.\n\n\ - Use: pred create --example ConjunctiveQueryFoldability\n\ - Or provide a JSON file directly." - ) - } - - "EquilibriumPoint" => { - bail!( - "EquilibriumPoint has complex nested input (polynomial factor lists).\n\n\ - Use: pred create --example EquilibriumPoint\n\ - Or provide a JSON file directly." - ) - } - - // PartitionIntoPathsOfLength2 - "PartitionIntoPathsOfLength2" => { - let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create PartitionIntoPathsOfLength2 --graph 0-1,1-2,3-4,4-5" - ) - })?; - if graph.num_vertices() % 3 != 0 { - bail!( - "PartitionIntoPathsOfLength2 requires vertex count divisible by 3, got {}", - graph.num_vertices() - ); - } - ( - ser(problemreductions::models::graph::PartitionIntoPathsOfLength2::new(graph))?, - resolved_variant.clone(), - ) - } + if source >= num_vertices || sink >= num_vertices { + return Err(lbdp_validation_error( + "--source and --sink must be valid graph vertices", + usage, + )); + } + if source == sink { + return Err(lbdp_validation_error( + "--source and --sink must be distinct", + usage, + )); + } + if max_length == 0 { + return Err(lbdp_validation_error( + "--max-length must be positive", + usage, + )); + } + Ok(max_length) +} - // ConjunctiveBooleanQuery - "ConjunctiveBooleanQuery" => { - let usage = "Usage: pred create CBQ --domain-size 6 --relations \"2:0,3|1,3;3:0,1,5|1,2,5\" --conjuncts-spec \"0:v0,c3;0:v1,c3;1:v0,v1,c5\""; - let domain_size = args.domain_size.ok_or_else(|| { - anyhow::anyhow!("ConjunctiveBooleanQuery requires --domain-size\n\n{usage}") - })?; - let relations_str = args.relations.as_deref().ok_or_else(|| { - anyhow::anyhow!("ConjunctiveBooleanQuery requires --relations\n\n{usage}") - })?; - let conjuncts_str = args.conjuncts_spec.as_deref().ok_or_else(|| { - anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec\n\n{usage}") - })?; - // Parse relations: "arity:t1,t2|t3,t4;arity:t5,t6,t7|t8,t9,t10" - // An empty tuple list (e.g., "2:") produces an empty relation. - let relations: Vec = relations_str - .split(';') - .map(|rel_str| { - let rel_str = rel_str.trim(); - let (arity_str, tuples_str) = rel_str.split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid relation format: expected 'arity:tuples', got '{rel_str}'" - ) - })?; - let arity: usize = arity_str - .trim() - .parse() - .map_err(|e| anyhow::anyhow!("Invalid arity '{arity_str}': {e}"))?; - let tuples: Vec> = if tuples_str.trim().is_empty() { - Vec::new() - } else { - tuples_str - .split('|') - .filter(|t| !t.trim().is_empty()) - .map(|t| { - let tuple: Vec = t - .trim() - .split(',') - .map(|v| { - v.trim().parse::().map_err(|e| { - anyhow::anyhow!("Invalid tuple value: {e}") - }) - }) - .collect::>>()?; - if tuple.len() != arity { - bail!( - "Relation tuple has {} entries, expected arity {arity}", - tuple.len() - ); - } - for &val in &tuple { - if val >= domain_size { - bail!("Tuple value {val} >= domain-size {domain_size}"); - } - } - Ok(tuple) - }) - .collect::>>()? - }; - Ok(CbqRelation { arity, tuples }) - }) - .collect::>>()?; - // Parse conjuncts: "rel_idx:arg1,arg2;rel_idx:arg1,arg2,arg3" - let mut num_vars_inferred: usize = 0; - let conjuncts: Vec<(usize, Vec)> = conjuncts_str - .split(';') - .map(|conj_str| { - let conj_str = conj_str.trim(); - let (idx_str, args_str) = conj_str.split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid conjunct format: expected 'rel_idx:args', got '{conj_str}'" - ) - })?; - let rel_idx: usize = idx_str.trim().parse().map_err(|e| { - anyhow::anyhow!("Invalid relation index '{idx_str}': {e}") - })?; - if rel_idx >= relations.len() { - bail!( - "Conjunct references relation {rel_idx}, but only {} relations exist", - relations.len() - ); - } - let query_args: Vec = args_str - .split(',') - .map(|a| { - let a = a.trim(); - if let Some(rest) = a.strip_prefix('v') { - let v: usize = rest.parse().map_err(|e| { - anyhow::anyhow!("Invalid variable index '{rest}': {e}") - })?; - if v + 1 > num_vars_inferred { - num_vars_inferred = v + 1; - } - Ok(QueryArg::Variable(v)) - } else if let Some(rest) = a.strip_prefix('c') { - let c: usize = rest.parse().map_err(|e| { - anyhow::anyhow!("Invalid constant value '{rest}': {e}") - })?; - if c >= domain_size { - bail!( - "Constant {c} >= domain-size {domain_size}" - ); - } - Ok(QueryArg::Constant(c)) - } else { - Err(anyhow::anyhow!( - "Invalid query arg '{a}': expected vN (variable) or cN (constant)" - )) - } - }) - .collect::>>()?; - let expected_arity = relations[rel_idx].arity; - if query_args.len() != expected_arity { - bail!( - "Conjunct has {} args, but relation {rel_idx} has arity {expected_arity}", - query_args.len() - ); - } - Ok((rel_idx, query_args)) - }) - .collect::>>()?; - ( - ser(ConjunctiveBooleanQuery::new( - domain_size, - relations, - num_vars_inferred, - conjuncts, - ))?, - resolved_variant.clone(), - ) - } +/// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). +fn resolved_graph_type(variant: &BTreeMap) -> &str { + variant + .get("graph") + .map(|s| s.as_str()) + .unwrap_or("SimpleGraph") +} - // PartiallyOrderedKnapsack - "PartiallyOrderedKnapsack" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "PartiallyOrderedKnapsack requires --sizes, --values, and --capacity (--precedences is optional)\n\n\ - Usage: pred create PartiallyOrderedKnapsack --sizes 2,3,4,1,2,3 --values 3,2,5,4,3,8 --precedences \"0>2,0>3,1>4,3>5,4>5\" --capacity 11" - ) - })?; - let values_str = args.values.as_deref().ok_or_else(|| { - anyhow::anyhow!("PartiallyOrderedKnapsack requires --values (e.g., 3,2,5,4,3,8)") - })?; - let cap_str = args.capacity.as_deref().ok_or_else(|| { - anyhow::anyhow!("PartiallyOrderedKnapsack requires --capacity (e.g., 11)") - })?; - let weights: Vec = util::parse_comma_list(sizes_str)?; - let values: Vec = util::parse_comma_list(values_str)?; - let capacity: i64 = cap_str.parse()?; - let precedences = match args.precedences.as_deref() { - Some(s) if !s.trim().is_empty() => s - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('>').collect(); - anyhow::ensure!( - parts.len() == 2, - "Invalid precedence format '{}', expected 'a>b'", - pair.trim() - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) - }) - .collect::>>()?, - _ => vec![], - }; - ( - ser(PartiallyOrderedKnapsack::new( - weights, - values, - precedences, - capacity, - ))?, - resolved_variant.clone(), - ) - } +pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { + if args.example.is_some() { + return create_from_example(args, out); + } - // PrimeAttributeName - "PrimeAttributeName" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "PrimeAttributeName requires --universe, --dependencies, and --query-attribute\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" - ) - })?; - let deps_str = args.dependencies.as_deref().or(args.deps.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "PrimeAttributeName requires --dependencies\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" - ) - })?; - let query = args.query.ok_or_else(|| { - anyhow::anyhow!( - "PrimeAttributeName requires --query-attribute\n\n\ - Usage: pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" - ) - })?; - let dependencies = parse_deps(deps_str)?; - for (i, (lhs, rhs)) in dependencies.iter().enumerate() { - for &attr in lhs.iter().chain(rhs.iter()) { - if attr >= universe { - bail!( - "Dependency {} references attribute {} outside universe of size {}", - i, - attr, - universe - ); + let problem = args.problem.as_ref().ok_or_else(|| { + anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") + })?; + let rgraph = problemreductions::rules::ReductionGraph::new(); + let resolved = match resolve_problem_ref(problem, &rgraph) { + Ok(resolved) => resolved, + Err(graph_err) => match resolve_catalog_problem_ref(problem) { + Ok(catalog_resolved) => { + if rgraph.variants_for(catalog_resolved.name()).is_empty() { + ProblemRef { + name: catalog_resolved.name().to_string(), + variant: catalog_resolved.variant().clone(), } + } else { + return Err(graph_err); } } - if query >= universe { - bail!( - "Query attribute {} is outside universe of size {}", - query, - universe - ); - } - ( - ser(PrimeAttributeName::new(universe, dependencies, query))?, - resolved_variant.clone(), - ) - } - - // SequencingWithReleaseTimesAndDeadlines - "SequencingWithReleaseTimesAndDeadlines" => { - let lengths_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithReleaseTimesAndDeadlines requires --lengths, --release-times, and --deadlines\n\n\ - Usage: pred create SequencingWithReleaseTimesAndDeadlines --lengths 3,2,4 --release-times 0,1,5 --deadlines 5,6,10" - ) - })?; - let release_str = args.release_times.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithReleaseTimesAndDeadlines requires --release-times\n\n\ - Usage: pred create SequencingWithReleaseTimesAndDeadlines --lengths 3,2,4 --release-times 0,1,5 --deadlines 5,6,10" - ) - })?; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingWithReleaseTimesAndDeadlines requires --deadlines\n\n\ - Usage: pred create SequencingWithReleaseTimesAndDeadlines --lengths 3,2,4 --release-times 0,1,5 --deadlines 5,6,10" - ) - })?; - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let release_times: Vec = util::parse_comma_list(release_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - if lengths.len() != release_times.len() || lengths.len() != deadlines.len() { - bail!( - "All three lists must have the same length: lengths={}, release_times={}, deadlines={}", - lengths.len(), - release_times.len(), - deadlines.len() - ); - } - ( - ser(SequencingWithReleaseTimesAndDeadlines::new( - lengths, - release_times, - deadlines, - ))?, - resolved_variant.clone(), - ) - } - - // StringToStringCorrection - "StringToStringCorrection" => { - let usage = "Usage: pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2"; - let source_str = args.source_string.as_deref().ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") - })?; - let target_str = args.target_string.as_deref().ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound( - args.bound.ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --bound\n\n{usage}") - })?, - "StringToStringCorrection", - usage, - )?; - let parse_symbols = |s: &str| -> Result> { - if s.trim().is_empty() { - return Ok(Vec::new()); + Err(catalog_err) => { + let spec = parse_problem_spec(problem)?; + if rgraph.variants_for(&spec.name).is_empty() { + return Err(catalog_err); } - s.split(',') - .map(|v| v.trim().parse::().context("invalid symbol index")) - .collect() - }; - let source = parse_symbols(source_str)?; - let target = parse_symbols(target_str)?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |m| m + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - if alphabet_size < inferred { - anyhow::bail!( - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the strings", - alphabet_size, - inferred - ); + return Err(graph_err); } - ( - ser(StringToStringCorrection::new( - alphabet_size, - source, - target, - bound, - ))?, - resolved_variant.clone(), - ) - } + }, + }; + let canonical = resolved.name.as_str(); + let resolved_variant = resolved.variant.clone(); + let graph_type = resolved_graph_type(&resolved_variant); - // Clustering - "Clustering" => { - let usage = "Usage: pred create Clustering --distance-matrix \"0,1,1,3;1,0,1,3;1,1,0,3;3,3,3,0\" --k 2 --diameter-bound 1"; - let dist_str = args.distance_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Clustering requires --distance-matrix, --k, and --diameter-bound\n\n{usage}" - ) - })?; - let distance_matrix = parse_u64_matrix_rows(dist_str, "distance matrix")?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!("Clustering requires --k (number of clusters)\n\n{usage}") - })?; - let diameter_bound = args - .diameter_bound - .ok_or_else(|| anyhow::anyhow!("Clustering requires --diameter-bound\n\n{usage}"))? - as u64; - ( - ser(Clustering::new(distance_matrix, k, diameter_bound))?, - resolved_variant.clone(), - ) - } + if args.random { + return create_random(args, canonical, &resolved_variant, out); + } - // MinimumDecisionTree - "MinimumDecisionTree" => { - let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; - let matrix_str = args.test_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumDecisionTree requires --test-matrix\n\n{usage}") - })?; - let test_matrix: Vec> = serde_json::from_str(matrix_str) - .context("Failed to parse --test-matrix as JSON 2D bool array")?; - let num_objects = args.num_objects.ok_or_else(|| { - anyhow::anyhow!("MinimumDecisionTree requires --num-objects\n\n{usage}") - })?; - let num_tests = args.num_tests.ok_or_else(|| { - anyhow::anyhow!("MinimumDecisionTree requires --num-tests\n\n{usage}") - })?; - ( - ser(MinimumDecisionTree::new( - test_matrix, - num_objects, - num_tests, - ))?, - resolved_variant.clone(), - ) - } + // ILP and CircuitSAT have complex input structures not suited for CLI flags. + // Check before the empty-flags help so they get a clear message. + if canonical == "ILP" || canonical == "CircuitSAT" { + bail!( + "CLI creation is not yet supported for {canonical}.\n\n\ + {canonical} instances are typically created via reduction:\n\ + pred create MIS --graph 0-1,1-2 | pred reduce - --to {canonical}\n\n\ + Or use the Rust API for direct construction." + ); + } - // MinimumDisjunctiveNormalForm - "MinimumDisjunctiveNormalForm" => { - let usage = "Usage: pred create MinDNF --num-vars 3 --truth-table 0,1,1,1,1,1,1,0"; - let num_vars = args.num_vars.ok_or_else(|| { - anyhow::anyhow!("MinimumDisjunctiveNormalForm requires --num-vars\n\n{usage}") - })?; - let tt_str = args.truth_table.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumDisjunctiveNormalForm requires --truth-table\n\n{usage}") - })?; - let truth_table: Vec = tt_str - .split(',') - .map(|s| match s.trim() { - "1" | "true" => Ok(true), - "0" | "false" => Ok(false), - other => bail!("Invalid truth table entry '{}': expected 0 or 1", other), - }) - .collect::>>()?; - ( - ser(MinimumDisjunctiveNormalForm::new(num_vars, truth_table))?, - resolved_variant.clone(), - ) - } + // Show schema-driven help when no data flags are provided + if all_data_flags_empty(args) { + print_problem_help(canonical, &resolved_variant)?; + std::process::exit(2); + } - // SquareTiling - "SquareTiling" => { - let usage = "Usage: pred create SquareTiling --num-colors 3 --tiles \"0,1,2,0;0,0,2,1;2,1,0,0;2,0,0,1\" --grid-size 2"; - let num_colors = args - .num_colors - .ok_or_else(|| anyhow::anyhow!("SquareTiling requires --num-colors\n\n{usage}"))?; - let tiles_str = args - .tiles - .as_deref() - .ok_or_else(|| anyhow::anyhow!("SquareTiling requires --tiles\n\n{usage}"))?; - let tiles: Vec<(usize, usize, usize, usize)> = tiles_str - .split(';') - .map(|tile_s| { - let parts: Vec = tile_s - .split(',') - .map(|v| { - v.trim() - .parse::() - .context("invalid tile color index") - }) - .collect::>>()?; - if parts.len() != 4 { - bail!( - "Each tile must have exactly 4 values (top,right,bottom,left), got {}", - parts.len() - ); - } - Ok((parts[0], parts[1], parts[2], parts[3])) - }) - .collect::>>()?; - let grid_size = args - .grid_size - .ok_or_else(|| anyhow::anyhow!("SquareTiling requires --grid-size\n\n{usage}"))?; - ( - ser(SquareTiling::new(num_colors, tiles, grid_size))?, - resolved_variant.clone(), + let (data, variant) = create_schema_driven(args, canonical, &resolved_variant)? + .ok_or_else(|| { + anyhow::anyhow!( + "Schema-driven creation unexpectedly returned no instance for {canonical}. This indicates a missing parser, flag mapping, derived field, or schema/factory mismatch in create.rs." ) - } - - _ => bail!("{}", crate::problem_name::unknown_problem_error(canonical)), - }; + })?; let output = ProblemJsonOutput { problem_type: canonical.to_string(), @@ -11459,13 +6837,10 @@ mod tests { panic!("expected create command"); }; - let (data, variant) = create_schema_driven( - &args, - "BalancedCompleteBipartiteSubgraph", - &BTreeMap::new(), - ) - .expect("schema-driven create should parse") - .expect("schema-driven path should support balanced biclique"); + let (data, variant) = + create_schema_driven(&args, "BalancedCompleteBipartiteSubgraph", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support balanced biclique"); let entry = problemreductions::registry::find_variant_entry( "BalancedCompleteBipartiteSubgraph", @@ -11499,10 +6874,9 @@ mod tests { }; let resolved_variant = variant_map(&[("weight", "i32")]); - let (data, variant) = - create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) - .expect("schema-driven create should parse") - .expect("schema-driven path should support mixed chinese postman"); + let (data, variant) = create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support mixed chinese postman"); let entry = problemreductions::registry::find_variant_entry("MixedChinesePostman", &variant) @@ -11538,7 +6912,10 @@ mod tests { .expect("variant entry"); (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); assert_eq!(data["graph"]["positions"].as_array().unwrap().len(), 3); - assert_eq!(data["graph"]["edges"], serde_json::json!([[0, 1], [0, 2], [1, 2]])); + assert_eq!( + data["graph"]["edges"], + serde_json::json!([[0, 1], [0, 2], [1, 2]]) + ); } #[test] From 0440caa4cffcae113525ad556514505cff8bebc9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 17:21:43 +0800 Subject: [PATCH 11/13] chore: clean schema-driven create refactor --- problemreductions-cli/src/commands/create.rs | 9867 +++-------------- .../src/commands/create/schema_semantics.rs | 1308 +++ .../src/commands/create/schema_support.rs | 2519 +++++ .../src/commands/create/tests.rs | 2698 +++++ problemreductions-cli/src/util.rs | 1 + 5 files changed, 7999 insertions(+), 8394 deletions(-) create mode 100644 problemreductions-cli/src/commands/create/schema_semantics.rs create mode 100644 problemreductions-cli/src/commands/create/schema_support.rs create mode 100644 problemreductions-cli/src/commands/create/tests.rs diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 16001340..b407ffa1 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,63 +9,40 @@ use anyhow::{bail, Context, Result}; use num_bigint::BigUint; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ - AlgebraicEquationsOverGF2, ClosestVectorProblem, ConsecutiveBlockMinimization, - ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, FeasibleBasisExtension, - MinimumMatrixCover, MinimumMatrixDomination, MinimumWeightDecoding, - MinimumWeightSolutionToLinearEquations, QuadraticCongruences, QuadraticDiophantineEquations, - SimultaneousIncongruences, SparseMatrixCompression, BMF, + ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, + SparseMatrixCompression, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ - DirectedHamiltonianPath, DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, - HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, IntegralFlowBundles, - Kernel, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, - MinimumDummyActivitiesPert, MinimumGeometricConnectedDominatingSet, MinimumMaximalMatching, - MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow, - RootedTreeArrangement, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, - VertexCover, + GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, + LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, + MinimumDummyActivitiesPert, MinimumMaximalMatching, RootedTreeArrangement, SteinerTree, + SteinerTreeInGraphs, VertexCover, }; use problemreductions::models::misc::{ - AdditionalKey, Betweenness, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, - CbqRelation, Clustering, ConjunctiveBooleanQuery, ConsistencyOfDatabaseFrequencyTables, - CyclicOrdering, DynamicStorageAllocation, EnsembleComputation, ExpectedRetrievalCost, - FeasibleRegisterAssignment, FlowShopScheduling, FrequencyTable, GroupingBySwapping, IntExpr, - IntegerExpressionMembership, JobShopScheduling, KnownValue, KthLargestMTuple, - LongestCommonSubsequence, MaximumLikelihoodRanking, MinimumAxiomSet, - MinimumCodeGenerationOneRegister, MinimumCodeGenerationParallelAssignments, - MinimumCodeGenerationUnlimitedRegisters, MinimumDecisionTree, MinimumDisjunctiveNormalForm, - MinimumExternalMacroDataCompression, MinimumFaultDetectionTestSet, - MinimumInternalMacroDataCompression, MinimumRegisterSufficiencyForLoops, - MinimumTardinessSequencing, MinimumWeightAndOrGraph, MultiprocessorScheduling, - NonLivenessFreePetriNet, Numerical3DimensionalMatching, OpenShopScheduling, PaintShop, - PartiallyOrderedKnapsack, PreemptiveScheduling, ProductionPlanning, QueryArg, - RectilinearPictureCompression, RegisterSufficiency, ResourceConstrainedScheduling, - SchedulingToMinimizeWeightedCompletionTime, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, - SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithDeadlinesAndSetUpTimes, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, SquareTiling, StringToStringCorrection, - SubsetProduct, SubsetSum, SumOfSquaresPartition, ThreePartition, TimetableDesign, + CbqRelation, FrequencyTable, KnownValue, QueryArg, SchedulingWithIndividualDeadlines, + ThreePartition, }; -use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; -use problemreductions::types::One; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; +mod schema_semantics; +use self::schema_semantics::validate_schema_driven_semantics; +mod schema_support; +use self::schema_support::*; + const MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS: &str = "--graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1"; const MULTIPLE_COPY_FILE_ALLOCATION_USAGE: &str = "Usage: pred create MultipleCopyFileAllocation --graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1"; const EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS: &str = "--probabilities 0.2,0.15,0.15,0.2,0.1,0.2 --num-sectors 3"; -const EXPECTED_RETRIEVAL_COST_USAGE: &str = - "Usage: pred create ExpectedRetrievalCost --probabilities 0.2,0.15,0.15,0.2,0.1,0.2 --num-sectors 3"; /// Check if all data flags are None (no problem-specific input provided). fn all_data_flags_empty(args: &CreateArgs) -> bool { @@ -599,8618 +576,1720 @@ fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { emit_problem_output(&output, out) } -#[derive(Debug, Clone, Default)] -struct CreateContext { - num_vertices: Option, - num_edges: Option, - num_arcs: Option, - parsed_fields: BTreeMap, +fn resolved_graph_type(variant: &BTreeMap) -> &str { + variant + .get("graph") + .map(|s| s.as_str()) + .unwrap_or("SimpleGraph") } -impl CreateContext { - #[cfg(test)] - fn with_field(mut self, name: &str, value: serde_json::Value) -> Self { - self.parsed_fields.insert(name.to_string(), value); - self - } - - fn seed_field(&mut self, name: &str, value: T) -> Result<()> { - let value = serde_json::to_value(value)?; - if name == "num_vertices" { - self.num_vertices = value.as_u64().and_then(|raw| usize::try_from(raw).ok()); - } - self.parsed_fields.insert(name.to_string(), value); - Ok(()) - } - - fn usize_field(&self, name: &str) -> Option { - self.parsed_fields - .get(name) - .and_then(serde_json::Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) - } - - fn f64_field(&self, name: &str) -> Option { - self.parsed_fields - .get(name) - .and_then(serde_json::Value::as_f64) +pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { + if args.example.is_some() { + return create_from_example(args, out); } - fn remember(&mut self, name: &str, concrete_type: &str, value: &serde_json::Value) { - self.parsed_fields.insert(name.to_string(), value.clone()); - - match normalize_type_name(concrete_type).as_str() { - "SimpleGraph" => { - self.num_vertices = value - .get("num_vertices") - .and_then(serde_json::Value::as_u64) - .and_then(|raw| usize::try_from(raw).ok()); - self.num_edges = value - .get("edges") - .and_then(serde_json::Value::as_array) - .map(Vec::len); - } - "DirectedGraph" => { - self.num_vertices = value - .get("num_vertices") - .and_then(serde_json::Value::as_u64) - .and_then(|raw| usize::try_from(raw).ok()); - self.num_arcs = value - .get("arcs") - .and_then(serde_json::Value::as_array) - .map(Vec::len); - } - "KingsSubgraph" | "TriangularSubgraph" => { - self.num_vertices = value - .get("positions") - .and_then(serde_json::Value::as_array) - .map(Vec::len); + let problem = args.problem.as_ref().ok_or_else(|| { + anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") + })?; + let rgraph = problemreductions::rules::ReductionGraph::new(); + let resolved = match resolve_problem_ref(problem, &rgraph) { + Ok(resolved) => resolved, + Err(graph_err) => match resolve_catalog_problem_ref(problem) { + Ok(catalog_resolved) => { + if rgraph.variants_for(catalog_resolved.name()).is_empty() { + ProblemRef { + name: catalog_resolved.name().to_string(), + variant: catalog_resolved.variant().clone(), + } + } else { + return Err(graph_err); + } } - "UnitDiskGraph" => { - self.num_vertices = value - .get("positions") - .and_then(serde_json::Value::as_array) - .map(Vec::len); - self.num_edges = value - .get("edges") - .and_then(serde_json::Value::as_array) - .map(Vec::len); + Err(catalog_err) => { + let spec = parse_problem_spec(problem)?; + if rgraph.variants_for(&spec.name).is_empty() { + return Err(catalog_err); + } + return Err(graph_err); } - _ => {} - } - } -} + }, + }; + let canonical = resolved.name.as_str(); + let resolved_variant = resolved.variant.clone(); -fn create_schema_driven( - args: &CreateArgs, - canonical: &str, - resolved_variant: &BTreeMap, -) -> Result)>> { - if !schema_driven_supported_problem(canonical) { - return Ok(None); + if args.random { + return create_random(args, canonical, &resolved_variant, out); } - let Some(schema) = collect_schemas() - .into_iter() - .find(|schema| schema.name == canonical) - else { - return Ok(None); - }; - let Some(variant_entry) = - problemreductions::registry::find_variant_entry(canonical, resolved_variant) - else { - return Ok(None); - }; + // ILP and CircuitSAT have complex input structures not suited for CLI flags. + // Check before the empty-flags help so they get a clear message. + if canonical == "ILP" || canonical == "CircuitSAT" { + bail!( + "CLI creation is not yet supported for {canonical}.\n\n\ + {canonical} instances are typically created via reduction:\n\ + pred create MIS --graph 0-1,1-2 | pred reduce - --to {canonical}\n\n\ + Or use the Rust API for direct construction." + ); + } - let graph_type = resolved_graph_type(resolved_variant); - let is_geometry = matches!( - graph_type, - "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" - ); - let flag_map = args.flag_map(); - let mut context = CreateContext::default(); - seed_schema_context_from_cli(args, graph_type, &mut context)?; - validate_schema_driven_semantics(args, canonical, resolved_variant, &serde_json::Value::Null) - .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; - let mut json_map = serde_json::Map::new(); - - for field in &schema.fields { - let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); - let flag_keys = - schema_field_flag_keys(canonical, &field.name, &field.type_name, is_geometry); - let raw_value = get_schema_flag_value(&flag_map, &flag_keys); - let value = if !schema_field_requires_derived_input(&field.name, &concrete_type) { - if let Some(raw_value) = raw_value.clone() { - match parse_schema_field_value( - args, - canonical, - &concrete_type, - &field.name, - &raw_value, - &context, - ) { - Ok(value) => value, - Err(error) => { - return Err(with_schema_usage(error, canonical, resolved_variant)) - } - } - } else if let Some(derived) = - derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? - { - derived - } else { - return Err(with_schema_usage( - missing_schema_field_error( - canonical, - &field.name, - &field.type_name, - is_geometry, - ), - canonical, - resolved_variant, - )); - } - } else if let Some(derived) = - derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? - { - derived - } else if let Some(raw_value) = raw_value { - match parse_schema_field_value( - args, - canonical, - &concrete_type, - &field.name, - &raw_value, - &context, - ) { - Ok(value) => value, - Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), - } - } else { - return Err(with_schema_usage( - missing_schema_field_error(canonical, &field.name, &field.type_name, is_geometry), - canonical, - resolved_variant, - )); - }; - - context.remember(&field.name, &concrete_type, &value); - json_map.insert(field.name.clone(), value); + // Show schema-driven help when no data flags are provided + if all_data_flags_empty(args) { + print_problem_help(canonical, &resolved_variant)?; + std::process::exit(2); } - let data = serde_json::Value::Object(json_map); - validate_schema_driven_semantics(args, canonical, resolved_variant, &data) - .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; - (variant_entry.factory)(data.clone()).map_err(|error| { - with_schema_usage( + let (data, variant) = create_schema_driven(args, canonical, &resolved_variant)? + .ok_or_else(|| { anyhow::anyhow!( - "Schema-driven factory rejected generated data for {canonical}: {error}" - ), - canonical, - resolved_variant, - ) - })?; - - Ok(Some((data, resolved_variant.clone()))) -} + "Schema-driven creation unexpectedly returned no instance for {canonical}. This indicates a missing parser, flag mapping, derived field, or schema/factory mismatch in create.rs." + ) + })?; -fn missing_schema_field_error( - canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> anyhow::Error { - let display = problem_help_flag_name(canonical, field_name, field_type, is_geometry); - let flags: Vec = display - .split('/') - .filter_map(|part| { - let trimmed = part.trim().trim_start_matches("--"); - (!trimmed.is_empty()).then(|| format!("--{trimmed}")) - }) - .collect(); - let requirement = match flags.as_slice() { - [] => format!("--{}", field_name.replace('_', "-")), - [flag] => flag.clone(), - [first, second] => format!("{first} or {second}"), - _ => { - let last = flags.last().cloned().unwrap_or_default(); - format!("{}, or {}", flags[..flags.len() - 1].join(", "), last) - } + let output = ProblemJsonOutput { + problem_type: canonical.to_string(), + variant, + data, }; - anyhow::anyhow!("{canonical} requires {requirement}") + + emit_problem_output(&output, out) } -fn parse_schema_field_value( - args: &CreateArgs, +/// Reject non-unit weights when the resolved variant uses `weight=One`. +fn reject_nonunit_weights_for_one_variant( canonical: &str, - concrete_type: &str, - field_name: &str, - raw: &str, - context: &CreateContext, -) -> Result { - match (canonical, field_name) { - ("BoyceCoddNormalFormViolation", "functional_deps") => { - let num_attributes = args.n.ok_or_else(|| { - anyhow::anyhow!("BoyceCoddNormalFormViolation requires --n, --sets, and --target") - })?; - Ok(serde_json::to_value(parse_bcnf_functional_deps( - raw, - num_attributes, - )?)?) - } - ("BoundedComponentSpanningForest", "max_weight") => { - 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 --max-weight 6"; - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") - })?; - let max_weight = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_weight)) - } - ("ConsecutiveBlockMinimization", "matrix") => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("FeasibleBasisExtension", "matrix") => { - let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("IntegralFlowBundles", "bundle_capacities") => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - Ok(serde_json::to_value(parse_bundle_capacities( - args, - bundles.len(), - usage, - )?)?) - } - ("IntegralFlowHomologousArcs", "homologous_pairs") => { - Ok(serde_json::to_value(parse_homologous_pairs(args)?)?) - } - ("LengthBoundedDisjointPaths", "max_length") => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let max_length = usize::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_length)) - } - ("LongestCommonSubsequence", "strings") => { - let (strings, _) = parse_lcs_strings(raw)?; - Ok(serde_json::to_value(strings)?) - } - ("MinimumDecisionTree", "test_matrix") => { - let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumDecisionTree requires --test-matrix as a JSON 2D bool array\n\n{usage}\n\nFailed to parse --test-matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightDecoding", "matrix") => { - let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightSolutionToLinearEquations", "matrix") => { - let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("GroupingBySwapping", "string") - | ("StringToStringCorrection", "source") - | ("StringToStringCorrection", "target") => { - Ok(serde_json::to_value(parse_symbol_list_allow_empty(raw)?)?) - } - ("MultipleCopyFileAllocation", "usage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.usage.as_deref(), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) - } - ("MultipleCopyFileAllocation", "storage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.storage.as_deref(), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) - } - ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => { - Ok(serde_json::to_value(parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?)?) - } - ("UndirectedTwoCommodityIntegralFlow", "capacities") => { - let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - Ok(serde_json::to_value(parse_capacities( - args, - graph.num_edges(), - usage, - )?)?) - } - _ => parse_field_value(concrete_type, field_name, raw, context), + graph_type: &str, + variant: &BTreeMap, + weights: &[i32], +) -> Result<()> { + if variant.get("weight").map(|w| w.as_str()) == Some("One") && weights.iter().any(|&w| w != 1) { + bail!( + "Non-unit weights are not supported for the default unit-weight variant.\n\n\ + Use the weighted variant instead:\n \ + pred create {canonical}/{graph_type}/i32 --graph ... --weights ..." + ); } + Ok(()) } -fn schema_driven_supported_problem(canonical: &str) -> bool { - canonical != "ILP" && canonical != "CircuitSAT" -} - -fn schema_field_flag_keys( +/// Create a vertex-weight problem dispatching on geometry graph type. +/// Serialize a vertex-weight problem with a generic graph type. +fn ser_vertex_weight_problem_with( canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> Vec { - let mut keys = vec![field_name.replace('_', "-")]; - for display_key in problem_help_flag_name(canonical, field_name, field_type, is_geometry) - .split('/') - .map(|key| key.trim().trim_start_matches("--").to_string()) - .filter(|key| !key.is_empty()) - { - if !keys.contains(&display_key) { - keys.push(display_key); - } + graph: G, + weights: Vec, +) -> Result { + match canonical { + "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights)), + "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights)), + "MaximumClique" => ser(MaximumClique::new(graph, weights)), + "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights)), + "MaximalIS" => ser(MaximalIS::new(graph, weights)), + _ => unreachable!(), } - keys } -fn get_schema_flag_value( - flag_map: &std::collections::HashMap<&'static str, Option>, - keys: &[String], -) -> Option { - keys.iter() - .find_map(|key| flag_map.get(key.as_str()).cloned().flatten()) +fn ser(problem: T) -> Result { + util::ser(problem) } -fn resolve_schema_field_type( - type_name: &str, - resolved_variant: &BTreeMap, -) -> String { - let normalized = normalize_type_name(type_name); - let graph_type = resolved_variant - .get("graph") - .map(String::as_str) - .unwrap_or("SimpleGraph"); - let weight_type = resolved_variant - .get("weight") - .map(String::as_str) - .unwrap_or("One"); - - match normalized.as_str() { - "G" => graph_type.to_string(), - "W" => weight_type.to_string(), - "W::Sum" => weight_sum_type(weight_type).to_string(), - "Vec" => format!("Vec<{weight_type}>"), - "Vec>" => format!("Vec>"), - "Vec<(usize,usize,W)>" => format!("Vec<(usize,usize,{weight_type})>"), - "Vec>" => format!("Vec>"), - other => other.to_string(), +fn parse_kclique_threshold( + k_flag: Option, + num_vertices: usize, + usage: &str, +) -> Result { + let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires --k\n\n{usage}"))?; + if k == 0 { + bail!("KClique: --k must be positive"); } -} - -fn weight_sum_type(weight_type: &str) -> &'static str { - match weight_type { - "One" | "i32" => "i32", - "f64" => "f64", - _ => "i32", + if k > num_vertices { + bail!("KClique: k must be <= graph num_vertices"); } + Ok(k) } -fn seed_schema_context_from_cli( - args: &CreateArgs, - graph_type: &str, - context: &mut CreateContext, -) -> Result<()> { - if let Some(num_vertices) = args.num_vertices { - context.seed_field("num_vertices", num_vertices)?; - } - if graph_type == "UnitDiskGraph" { - context.seed_field("radius", args.radius.unwrap_or(1.0))?; - } - Ok(()) +fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { + util::variant_map(pairs) } -fn derive_schema_field_value( +fn parse_bipartite_problem_input( args: &CreateArgs, canonical: &str, - field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - if let Some(defaulted) = - derive_schema_default_value(canonical, field_name, concrete_type, context)? - { - return Ok(Some(defaulted)); - } - - if field_name == "graph" && concrete_type == "MixedGraph" { - let usage = format!( - "Usage: pred create {canonical} {}", - example_for(canonical, None) - ); - return Ok(Some(serde_json::to_value(parse_mixed_graph( - args, &usage, - )?)?)); - } + k_description: &str, + usage: &str, +) -> Result<(BipartiteGraph, usize)> { + let left = args.left.ok_or_else(|| { + anyhow::anyhow!( + "{canonical} requires --left, --right, --biedges, and --k\n\nUsage: {usage}" + ) + })?; + let right = args.right.ok_or_else(|| { + anyhow::anyhow!("{canonical} requires --right (right partition size)\n\nUsage: {usage}") + })?; + let k = args.k.ok_or_else(|| { + anyhow::anyhow!("{canonical} requires --k ({k_description})\n\nUsage: {usage}") + })?; + let edges_str = args.biedges.as_deref().ok_or_else(|| { + anyhow::anyhow!("{canonical} requires --biedges (e.g., 0-0,0-1,1-1)\n\nUsage: {usage}") + })?; + let edges = util::parse_edge_pairs(edges_str)?; + validate_bipartite_edges(canonical, left, right, &edges)?; + Ok((BipartiteGraph::new(left, right, edges), k)) +} - if field_name == "graph" && concrete_type == "BipartiteGraph" { - let left = args - .left - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; - let right = args - .right - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; - let edges_raw = args - .biedges - .as_deref() - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --biedges"))?; - let edges = util::parse_edge_pairs(edges_raw)?; - validate_bipartite_edges(canonical, left, right, &edges)?; - return Ok(Some(serde_json::to_value(BipartiteGraph::new( - left, right, edges, - ))?)); +fn validate_bipartite_edges( + canonical: &str, + left: usize, + right: usize, + edges: &[(usize, usize)], +) -> Result<()> { + for &(u, v) in edges { + if u >= left { + bail!("{canonical} edge {u}-{v} is out of bounds for left partition size {left}"); + } + if v >= right { + bail!("{canonical} edge {u}-{v} is out of bounds for right partition size {right}"); + } } + Ok(()) +} - if canonical == "ClosestVectorProblem" - && field_name == "bounds" - && normalize_type_name(concrete_type) == "Vec" - { - return Ok(Some(parse_cvp_bounds_value( - args.bounds.as_deref(), - context, - )?)); - } +/// Parse `--graph` into a SimpleGraph, optionally preserving isolated vertices +/// via `--num-vertices`. +fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { + let edges_str = args + .graph + .as_deref() + .ok_or_else(|| anyhow::anyhow!("This problem requires --graph (e.g., 0-1,1-2,2-3)"))?; - if canonical == "ConjunctiveBooleanQuery" - && field_name == "num_variables" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .conjuncts_spec - .as_deref() - .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec"))?; - return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); + if edges_str.trim().is_empty() { + let num_vertices = args.num_vertices.ok_or_else(|| { + anyhow::anyhow!( + "Empty graph string. To create a graph with isolated vertices, pass --num-vertices N as well." + ) + })?; + return Ok((SimpleGraph::empty(num_vertices), num_vertices)); } - if canonical == "GroupingBySwapping" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .string - .as_deref() - .ok_or_else(|| anyhow::anyhow!("GroupingBySwapping requires --string"))?; - let string = parse_symbol_list_allow_empty(raw)?; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } - - if canonical == "JobShopScheduling" - && field_name == "num_processors" - && normalize_type_name(concrete_type) == "usize" - { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let inferred_processors = match args.job_tasks.as_deref() { - Some(job_tasks) => { - let jobs = parse_job_shop_jobs(job_tasks)?; - jobs.iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1) + let edges: Vec<(usize, usize)> = edges_str + .split(',') + .map(|pair| { + let parts: Vec<&str> = pair.trim().split('-').collect(); + if parts.len() != 2 { + bail!("Invalid edge '{}': expected format u-v", pair.trim()); } - None => None, - }; - let num_processors = - resolve_processor_count_flags("JobShopScheduling", usage, args.num_processors, args.m)? - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - return Ok(Some(serde_json::json!(num_processors))); - } - - if canonical == "LongestCommonSubsequence" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .strings - .as_deref() - .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; - let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred_alphabet_size)))); - } + let u: usize = parts[0].parse()?; + let v: usize = parts[1].parse()?; + if u == v { + bail!( + "Self-loop detected: edge {}-{}. Simple graphs do not allow self-loops", + u, + v + ); + } + Ok((u, v)) + }) + .collect::>>()?; - if canonical == "LongestCommonSubsequence" - && field_name == "max_length" - && normalize_type_name(concrete_type) == "usize" - { - let strings: Vec> = - serde_json::from_value(context.parsed_fields.get("strings").cloned().ok_or_else( - || anyhow::anyhow!("LCS max_length derivation requires parsed strings"), - )?)?; - let max_length = strings.iter().map(Vec::len).min().unwrap_or(0); - return Ok(Some(serde_json::json!(max_length))); - } + let inferred_num_vertices = edges + .iter() + .flat_map(|(u, v)| [*u, *v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let num_vertices = match args.num_vertices { + Some(explicit) if explicit < inferred_num_vertices => { + bail!( + "--num-vertices {} is too small for the provided graph; need at least {}", + explicit, + inferred_num_vertices + ); + } + Some(explicit) => explicit, + None => inferred_num_vertices, + }; - if canonical == "QUBO" - && field_name == "num_vars" - && normalize_type_name(concrete_type) == "usize" - { - let matrix = parse_matrix(args)?; - return Ok(Some(serde_json::json!(matrix.len()))); - } + Ok((SimpleGraph::new(num_vertices, edges), num_vertices)) +} - if canonical == "StringToStringCorrection" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let source = parse_symbol_list_allow_empty(args.source_string.as_deref().unwrap_or(""))?; - let target = parse_symbol_list_allow_empty(args.target_string.as_deref().unwrap_or(""))?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } +/// Parse `--positions` as integer grid positions. +fn parse_int_positions(args: &CreateArgs) -> Result> { + let pos_str = args.positions.as_deref().ok_or_else(|| { + anyhow::anyhow!("This variant requires --positions (e.g., \"0,0;1,0;1,1\")") + })?; + util::parse_positions(pos_str, "0,0") +} - if field_name == "precedences" - && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" - && args.precedences.is_none() - && args.precedence_pairs.is_none() - { - return Ok(Some(serde_json::json!([]))); - } +/// Parse `--positions` as float positions. +fn parse_float_positions(args: &CreateArgs) -> Result> { + let pos_str = args.positions.as_deref().ok_or_else(|| { + anyhow::anyhow!("This variant requires --positions (e.g., \"0.0,0.0;1.0,0.0;0.5,0.87\")") + })?; + util::parse_positions(pos_str, "0.0,0.0") +} - if canonical == "ComparativeContainment" - && matches!(field_name, "r_weights" | "s_weights") - && matches!( - normalize_type_name(concrete_type).as_str(), - "Vec" | "Vec" | "Vec" - ) - { - let sets_len = context - .parsed_fields - .get(match field_name { - "r_weights" => "r_sets", - _ => "s_sets", - }) - .and_then(serde_json::Value::as_array) - .map(Vec::len); - if let Some(len) = sets_len { - let value = match normalize_type_name(concrete_type).as_str() { - "Vec" | "Vec" => serde_json::json!(vec![1_i32; len]), - "Vec" => serde_json::json!(vec![1.0_f64; len]), - _ => unreachable!(), - }; - return Ok(Some(value)); +/// Parse `--weights` as vertex weights (i32), defaulting to all 1s. +fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result> { + match &args.weights { + Some(w) => { + let weights: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if weights.len() != num_vertices { + bail!( + "Expected {} weights but got {}", + num_vertices, + weights.len() + ); + } + Ok(weights) } + None => Ok(vec![1i32; num_vertices]), } +} - if canonical == "ConsistencyOfDatabaseFrequencyTables" - && field_name == "known_values" - && normalize_type_name(concrete_type) == "Vec" - && args.known_values.is_none() - { - return Ok(Some(serde_json::json!([]))); - } - - if canonical == "LengthBoundedDisjointPaths" - && field_name == "max_paths" - && normalize_type_name(concrete_type) == "usize" - { - let graph_value = context.parsed_fields.get("graph").cloned(); - let source = context.usize_field("source"); - let sink = context.usize_field("sink"); - if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { - let graph: SimpleGraph = - serde_json::from_value(graph_value).context("Failed to deserialize graph")?; - let max_paths = graph - .neighbors(source) - .len() - .min(graph.neighbors(sink).len()); - return Ok(Some(serde_json::json!(max_paths))); +fn parse_i32_edge_values( + values: Option<&String>, + num_edges: usize, + value_label: &str, +) -> Result> { + match values { + Some(raw) => { + let parsed: Vec = raw + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if parsed.len() != num_edges { + bail!( + "Expected {} {} values but got {}", + num_edges, + value_label, + parsed.len() + ); + } + Ok(parsed) } + None => Ok(vec![1i32; num_edges]), } - - Ok(None) } -fn derive_schema_default_value( - canonical: &str, +fn parse_vertex_i64_values( + raw: Option<&str>, field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - let normalized = normalize_type_name(concrete_type); - - let one_list = |len: usize| match normalized.as_str() { - "Vec" | "Vec" => Some(serde_json::json!(vec![1_i32; len])), - "Vec" => Some(serde_json::json!(vec![1_u64; len])), - "Vec" => Some(serde_json::json!(vec![1_i64; len])), - "Vec" => Some(serde_json::json!(vec![1_usize; len])), - "Vec" => Some(serde_json::json!(vec![1.0_f64; len])), - _ => None, - }; - - let derived = match field_name { - "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), - "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), - "arc_weights" | "arc_lengths" if context.num_arcs.is_some() => { - context.num_arcs.and_then(one_list) - } - "capacities" if canonical == "PathConstrainedNetworkFlow" => { - context.num_arcs.and_then(one_list) - } - "couplings" if canonical == "SpinGlass" => context.num_edges.and_then(one_list), - "fields" if canonical == "SpinGlass" => match normalized.as_str() { - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0_i32; len])), - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0.0_f64; len])), - _ => None, - }, - _ => None, - }; - - Ok(derived) + num_vertices: usize, + problem_name: &str, + usage: &str, +) -> Result> { + let raw = + raw.ok_or_else(|| anyhow::anyhow!("{problem_name} requires --{field_name}\n\n{usage}"))?; + let values: Vec = util::parse_comma_list(raw) + .map_err(|e| anyhow::anyhow!("invalid {field_name} list: {e}\n\n{usage}"))?; + if values.len() != num_vertices { + bail!( + "Expected {} {} values but got {}\n\n{}", + num_vertices, + field_name, + values.len(), + usage + ); + } + Ok(values) } -fn schema_field_requires_derived_input(field_name: &str, concrete_type: &str) -> bool { - field_name == "graph" && matches!(concrete_type, "MixedGraph" | "BipartiteGraph") +/// Parse `--terminals` as comma-separated vertex indices. +fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> { + let s = args + .terminals + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--terminals required (e.g., \"0,2,4\")"))?; + let terminals: Vec = s + .split(',') + .map(|t| t.trim().parse::()) + .collect::, _>>() + .context("invalid terminal index")?; + for &t in &terminals { + anyhow::ensure!( + t < num_vertices, + "terminal {t} >= num_vertices ({num_vertices})" + ); + } + let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect(); + anyhow::ensure!( + distinct_terminals.len() == terminals.len(), + "terminals must be distinct" + ); + anyhow::ensure!(terminals.len() >= 2, "at least 2 terminals required"); + Ok(terminals) } -fn is_unsupported_schema_parser(error: &anyhow::Error) -> bool { - error.to_string().contains("Unsupported schema parser") -} +/// Parse `--terminal-pairs` as comma-separated `u-v` vertex pairs. +fn parse_terminal_pairs(args: &CreateArgs, num_vertices: usize) -> Result> { + let raw = args + .terminal_pairs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--terminal-pairs required (e.g., \"0-3,2-5\")"))?; + let terminal_pairs = util::parse_edge_pairs(raw)?; + anyhow::ensure!( + !terminal_pairs.is_empty(), + "at least 1 terminal pair required" + ); -fn with_schema_usage( - error: anyhow::Error, - canonical: &str, - resolved_variant: &BTreeMap, -) -> anyhow::Error { - let message = error.to_string(); - if message.contains("Usage: pred create") { - return error; + let mut used = BTreeSet::new(); + for &(source, sink) in &terminal_pairs { + anyhow::ensure!( + source < num_vertices, + "terminal pair source {source} >= num_vertices ({num_vertices})" + ); + anyhow::ensure!( + sink < num_vertices, + "terminal pair sink {sink} >= num_vertices ({num_vertices})" + ); + anyhow::ensure!(source != sink, "terminal pair endpoints must be distinct"); + anyhow::ensure!( + used.insert(source) && used.insert(sink), + "terminal vertices must be pairwise disjoint across terminal pairs" + ); } - let graph_type = resolved_variant.get("graph").map(String::as_str); - anyhow::anyhow!( - "{message}\n\nUsage: pred create {canonical} {}", - example_for(canonical, graph_type) - ) -} - -fn parse_field_value( - concrete_type: &str, - field_name: &str, - raw: &str, - context: &CreateContext, -) -> Result { - let normalized_type = normalize_type_name(concrete_type); - let value = match normalized_type.as_str() { - "SimpleGraph" => parse_simple_graph_value(raw, context)?, - "DirectedGraph" => parse_directed_graph_value(raw, context)?, - "KingsSubgraph" => parse_grid_subgraph_value(raw, true)?, - "TriangularSubgraph" => parse_grid_subgraph_value(raw, false)?, - "UnitDiskGraph" => parse_unit_disk_graph_value(raw, context)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_numeric_list_value::(raw)?, - "Vec" => parse_bool_list_value(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_nested_numeric_list_value::(raw)?, - "Vec>" => parse_bool_rows_value(raw, field_name)?, - "Vec>>" => parse_3d_numeric_list_value::(raw)?, - "Vec>>" => parse_3d_numeric_list_value::(raw)?, - "Vec<[usize;3]>" => parse_triple_array_list_value(raw)?, - "Vec" => serde_json::to_value(parse_clauses_raw(raw)?)?, - "Vec<(usize,usize)>" => parse_pair_list_value(raw)?, - "Vec<(u64,u64)>" => parse_semicolon_tuple_list_value::(raw)?, - "Vec<(usize,f64)>" => parse_indexed_numeric_pairs_value::(raw)?, - "Vec<(usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, - "Vec<(usize,usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, - "Vec<(usize,usize,One)>" => parse_weighted_edge_list_value::(raw)?, - "Vec<(usize,usize,i32)>" => parse_weighted_edge_list_value::(raw)?, - "Vec<(usize,usize,i64)>" => parse_weighted_edge_list_value::(raw)?, - "Vec<(usize,usize,u64)>" => parse_weighted_edge_list_value::(raw)?, - "Vec<(usize,usize,f64)>" => parse_weighted_edge_list_value::(raw)?, - "Vec<(Vec,Vec)>" => serde_json::to_value(parse_dependencies(raw)?)?, - "Vec<(Vec,usize)>" => serde_json::to_value(parse_implications(raw)?)?, - "Vec<(usize,Vec)>" => serde_json::to_value(parse_cbq_conjuncts(raw, context)?)?, - "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, - "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, - "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, - "Vec" => { - serde_json::to_value(parse_cdft_frequency_tables_value(raw, context)?)? - } - "Vec" => serde_json::to_value(parse_cdft_known_values_value(raw, context)?)?, - "Vec" => serde_json::to_value(parse_cbq_relations(raw, context)?)?, - "Vec" => parse_string_list_value(raw)?, - "Vec" => parse_cvp_bounds_value(Some(raw), context)?, - "Vec" => parse_biguint_list_value(raw)?, - "BigUint" => parse_biguint_value(raw)?, - "Vec>" => parse_optional_bool_list_value(raw)?, - "Vec" => serde_json::to_value(parse_quantifiers_raw(raw, context)?)?, - "IntExpr" => parse_json_passthrough_value(raw)?, - "bool" => serde_json::to_value(parse_bool_token(raw.trim())?)?, - "One" => serde_json::json!(1), - "usize" => parse_scalar_value::(raw)?, - "u64" => parse_scalar_value::(raw)?, - "i32" => parse_scalar_value::(raw)?, - "i64" => parse_scalar_value::(raw)?, - "f64" => parse_scalar_value::(raw)?, - other => bail!("Unsupported schema parser for field '{field_name}' with type '{other}'"), - }; - Ok(value) + Ok(terminal_pairs) } -fn normalize_type_name(type_name: &str) -> String { - type_name.chars().filter(|ch| !ch.is_whitespace()).collect() +fn ensure_positive_i32_values(values: &[i32], label: &str) -> Result<()> { + if values.iter().any(|&value| value <= 0) { + bail!("All {label} must be positive (> 0)"); + } + Ok(()) } -fn parse_scalar_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - Ok(serde_json::to_value(raw.trim().parse::().map_err( - |err| anyhow::anyhow!("Invalid value '{}': {err}", raw.trim()), - )?)?) +fn ensure_positive_i32(value: i32, label: &str) -> Result<()> { + if value <= 0 { + bail!("{label} must be positive (> 0)"); + } + Ok(()) } -fn parse_numeric_list_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - Ok(serde_json::to_value(util::parse_comma_list::(raw)?)?) +fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> Result<()> { + if vertex >= num_vertices { + bail!("{label} {vertex} out of bounds (graph has {num_vertices} vertices)"); + } + Ok(()) } -fn parse_bool_list_value(raw: &str) -> Result { - let values: Vec = raw - .split(',') - .map(|entry| parse_bool_token(entry.trim())) - .collect::>()?; - Ok(serde_json::to_value(values)?) +/// Parse `--edge-weights` as per-edge numeric values (i32), defaulting to all 1s. +fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { + parse_i32_edge_values(args.edge_weights.as_ref(), num_edges, "edge weight") } -fn parse_bool_rows_value(raw: &str, field_name: &str) -> Result { - let flag = format!("--{}", field_name.replace('_', "-")); - let rows = parse_bool_rows(raw) - .map_err(|err| anyhow::anyhow!("{}", err.to_string().replace("--matrix", &flag)))?; - Ok(serde_json::to_value(rows)?) -} +fn validate_vertex_index( + label: &str, + vertex: usize, + num_vertices: usize, + usage: &str, +) -> Result<()> { + if vertex < num_vertices { + return Ok(()); + } -fn parse_nested_numeric_list_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - let rows: Vec> = raw - .split(';') - .map(|row| util::parse_comma_list::(row.trim())) - .collect::>()?; - Ok(serde_json::to_value(rows)?) + bail!("{label} must be less than num_vertices ({num_vertices})\n\n{usage}"); } -fn parse_3d_numeric_list_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - let matrices: Vec>> = raw - .split('|') - .map(|matrix| { - matrix - .split(';') - .map(|row| util::parse_comma_list::(row.trim())) - .collect::>>() +/// Parse `--capacities` as edge capacities (u64). +fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { + let capacities = args + .capacities + .as_deref() + .ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?; + let capacities: Vec = capacities + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) }) - .collect::>()?; - Ok(serde_json::to_value(matrices)?) + .collect::>>()?; + if capacities.len() != num_edges { + bail!( + "Expected {} capacities but got {}\n\n{}", + num_edges, + capacities.len(), + usage + ); + } + Ok(capacities) } -fn parse_triple_array_list_value(raw: &str) -> Result { - let triples: Vec<[usize; 3]> = raw - .split(';') - .map(|entry| { - let values: Vec = util::parse_comma_list(entry.trim())?; - anyhow::ensure!( - values.len() == 3, - "Expected triple with exactly 3 entries, got {}", - values.len() - ); - Ok([values[0], values[1], values[2]]) - }) - .collect::>()?; - Ok(serde_json::to_value(triples)?) -} - -fn parse_clauses_raw(raw: &str) -> Result> { - raw.split(';') - .map(|clause| { - let literals: Vec = clause - .trim() - .split(',') - .map(|value| value.trim().parse::()) - .collect::, _>>()?; - Ok(CNFClause::new(literals)) - }) - .collect() -} - -fn parse_pair_list_value(raw: &str) -> Result { - let pairs: Vec<(usize, usize)> = raw +/// Parse `--lower-bounds` as edge lower bounds (u64). +fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { + let lower_bounds = args.lower_bounds.as_deref().ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}") + })?; + let lower_bounds: Vec = lower_bounds .split(',') - .map(|entry| { - let entry = entry.trim(); - let parts: Vec<&str> = if entry.contains('>') { - entry.split('>').collect() - } else { - entry.split('-').collect() - }; - anyhow::ensure!( - parts.len() == 2, - "Invalid pair '{entry}': expected u-v or u>v" - ); - Ok(( - parts[0].trim().parse::()?, - parts[1].trim().parse::()?, - )) + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}")) }) - .collect::>()?; - Ok(serde_json::to_value(pairs)?) -} - -fn infer_cbq_num_variables(raw: &str) -> Result { - let mut num_vars = 0usize; - for conjunct in raw.split(';').filter(|entry| !entry.trim().is_empty()) { - let (_, args_str) = conjunct.trim().split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid conjunct format: expected 'rel_idx:args', got '{}'", - conjunct.trim() - ) - })?; - for arg in args_str - .split(',') - .map(str::trim) - .filter(|arg| !arg.is_empty()) - { - if let Some(rest) = arg.strip_prefix('v') { - let index: usize = rest - .parse() - .map_err(|err| anyhow::anyhow!("Invalid variable index '{rest}': {err}"))?; - num_vars = num_vars.max(index + 1); - } - } + .collect::>>()?; + if lower_bounds.len() != num_edges { + bail!( + "Expected {} lower bounds but got {}\n\n{}", + num_edges, + lower_bounds.len(), + usage + ); } - Ok(num_vars) + Ok(lower_bounds) } -fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result> { - let domain_size = context.usize_field("domain_size").ok_or_else(|| { - anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field") +fn parse_bundle_capacities(args: &CreateArgs, num_bundles: usize, usage: &str) -> Result> { + let capacities = args.bundle_capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --bundle-capacities\n\n{usage}") })?; - - raw.split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|rel_str| { - let rel_str = rel_str.trim(); - let (arity_str, tuples_str) = rel_str.split_once(':').ok_or_else(|| { - anyhow::anyhow!("Invalid relation format: expected 'arity:tuples', got '{rel_str}'") - })?; - let arity: usize = arity_str - .trim() - .parse() - .map_err(|e| anyhow::anyhow!("Invalid arity '{arity_str}': {e}"))?; - let tuples: Vec> = if tuples_str.trim().is_empty() { - Vec::new() - } else { - tuples_str - .split('|') - .filter(|tuple| !tuple.trim().is_empty()) - .map(|tuple| { - let tuple: Vec = util::parse_comma_list(tuple.trim())?; - anyhow::ensure!( - tuple.len() == arity, - "Relation tuple has {} entries, expected arity {arity}", - tuple.len() - ); - for &value in &tuple { - anyhow::ensure!( - value < domain_size, - "Tuple value {value} >= domain-size {domain_size}" - ); - } - Ok(tuple) - }) - .collect::>()? - }; - Ok(CbqRelation { arity, tuples }) + let capacities: Vec = capacities + .split(',') + .map(|s| { + let trimmed = s.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid bundle capacity `{trimmed}`\n\n{usage}")) }) - .collect() + .collect::>>()?; + anyhow::ensure!( + capacities.len() == num_bundles, + "Expected {} bundle capacities but got {}\n\n{}", + num_bundles, + capacities.len(), + usage + ); + for (bundle_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + anyhow::ensure!( + fits, + "bundle capacity {} at bundle index {} is too large for this platform\n\n{}", + capacity, + bundle_index, + usage + ); + anyhow::ensure!( + capacity > 0, + "bundle capacity at bundle index {} must be positive\n\n{}", + bundle_index, + usage + ); + } + Ok(capacities) } -fn parse_cbq_conjuncts(raw: &str, context: &CreateContext) -> Result)>> { - let relations: Vec = - serde_json::from_value(context.parsed_fields.get("relations").cloned().ok_or_else( - || anyhow::anyhow!("CBQ conjunct parsing requires prior relations field"), - )?) - .context("Failed to deserialize parsed CBQ relations")?; - let domain_size = context - .usize_field("domain_size") - .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior domain_size field"))?; - let num_variables = context.usize_field("num_variables").unwrap_or(0); - - raw.split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|conj_str| { - let conj_str = conj_str.trim(); - let (idx_str, args_str) = conj_str.split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid conjunct format: expected 'rel_idx:args', got '{conj_str}'" - ) - })?; - let rel_idx: usize = idx_str - .trim() - .parse() - .map_err(|e| anyhow::anyhow!("Invalid relation index '{idx_str}': {e}"))?; - anyhow::ensure!( - rel_idx < relations.len(), - "Conjunct references relation {rel_idx}, but only {} relations exist", - relations.len() - ); +/// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. +/// Parse `--fields` as SpinGlass on-site fields (i32), defaulting to all 0s. +/// Check if a CLI string value contains float syntax (a decimal point). +/// Parse `--couplings` as SpinGlass pairwise couplings (f64), defaulting to all 1.0. +/// Parse `--fields` as SpinGlass on-site fields (f64), defaulting to all 0.0. +/// Parse `--clauses` as semicolon-separated clauses of comma-separated literals. +/// E.g., "1,2;-1,3;2,-3" +/// Parse `--subsets` as semicolon-separated sets of comma-separated usize. +/// E.g., "0,1;1,2;0,2" +fn parse_sets(args: &CreateArgs) -> Result>> { + parse_named_sets(args.sets.as_deref(), "--subsets") +} - let query_args: Vec = args_str +fn parse_named_sets(sets_str: Option<&str>, flag: &str) -> Result>> { + let sets_str = sets_str + .ok_or_else(|| anyhow::anyhow!("This problem requires {flag} (e.g., \"0,1;1,2;0,2\")"))?; + sets_str + .split(';') + .map(|set| { + set.trim() .split(',') - .map(|arg| { - let arg = arg.trim(); - if let Some(rest) = arg.strip_prefix('v') { - let variable: usize = rest - .parse() - .map_err(|e| anyhow::anyhow!("Invalid variable index '{rest}': {e}"))?; - anyhow::ensure!( - variable < num_variables, - "Variable({variable}) >= num_variables ({num_variables})" - ); - Ok(QueryArg::Variable(variable)) - } else if let Some(rest) = arg.strip_prefix('c') { - let constant: usize = rest - .parse() - .map_err(|e| anyhow::anyhow!("Invalid constant value '{rest}': {e}"))?; - anyhow::ensure!( - constant < domain_size, - "Constant {constant} >= domain-size {domain_size}" - ); - Ok(QueryArg::Constant(constant)) - } else { - Err(anyhow::anyhow!( - "Invalid query arg '{arg}': expected vN (variable) or cN (constant)" - )) - } + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid set element: {}", e)) }) - .collect::>()?; - anyhow::ensure!( - query_args.len() == relations[rel_idx].arity, - "Conjunct has {} args, but relation {rel_idx} has arity {}", - query_args.len(), - relations[rel_idx].arity - ); - Ok((rel_idx, query_args)) + .collect() }) .collect() } -fn parse_semicolon_tuple_list_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - let tuples: Vec> = raw - .split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let values: Vec = util::parse_comma_list(entry.trim())?; - anyhow::ensure!( - values.len() == N, - "Expected tuple with {N} entries, got {}", - values.len() - ); - Ok(values) - }) - .collect::>()?; - Ok(serde_json::to_value(tuples)?) -} +fn parse_homologous_pairs(args: &CreateArgs) -> Result> { + let pairs = args.homologous_pairs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "IntegralFlowHomologousArcs requires --homologous-pairs (e.g., \"2=5;4=3\")" + ) + })?; -fn parse_weighted_edge_list_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - let edges: Vec<(usize, usize, T)> = raw - .split(',') + pairs + .split(';') .filter(|entry| !entry.trim().is_empty()) .map(|entry| { let entry = entry.trim(); - let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { - anyhow::anyhow!("Invalid weighted edge '{entry}': expected u-v:w") + let (left, right) = entry.split_once('=').ok_or_else(|| { + anyhow::anyhow!( + "Invalid homologous pair '{}': expected format u=v (e.g., 2=5)", + entry + ) })?; - let (u_str, v_str) = if let Some((u, v)) = edge_part.split_once('-') { - (u, v) - } else if let Some((u, v)) = edge_part.split_once('>') { - (u, v) - } else { - bail!("Invalid weighted edge '{entry}': expected u-v:w or u>v:w"); - }; - Ok(( - u_str.trim().parse::()?, - v_str.trim().parse::()?, - weight_part.trim().parse::().map_err(|err| { - anyhow::anyhow!("Invalid edge weight '{}': {err}", weight_part.trim()) - })?, - )) + let left = left.trim().parse::().with_context(|| { + format!("Invalid homologous pair '{}': expected format u=v", entry) + })?; + let right = right.trim().parse::().with_context(|| { + format!("Invalid homologous pair '{}': expected format u=v", entry) + })?; + Ok((left, right)) }) - .collect::>()?; - Ok(serde_json::to_value(edges)?) + .collect() } -fn parse_indexed_numeric_pairs_value(raw: &str) -> Result -where - T: std::str::FromStr + Serialize, - T::Err: std::fmt::Display, -{ - let pairs: Vec<(usize, T)> = - raw.split(',') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let entry = entry.trim(); - let (index, value) = entry.split_once(':').ok_or_else(|| { - anyhow::anyhow!("Invalid pair '{entry}': expected index:value") - })?; - Ok(( - index.trim().parse::()?, - value.trim().parse::().map_err(|err| { - anyhow::anyhow!("Invalid value '{}': {err}", value.trim()) - })?, - )) +/// Parse a dependency string as semicolon-separated `lhs>rhs` pairs. +/// E.g., "0,1>2,3;2,3>0,1" +/// Parse a comma-separated list of usize indices. +/// Parse `--dependencies` as semicolon-separated "lhs>rhs" pairs. +/// E.g., "0,1>2;0,2>3;1,3>4;2,4>5" means {0,1}->{2}, {0,2}->{3}, etc. +fn parse_dependencies(input: &str) -> Result, Vec)>> { + fn parse_dependency_side(side: &str) -> Result> { + if side.trim().is_empty() { + return Ok(vec![]); + } + side.split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid attribute index: {}", e)) }) - .collect::>()?; - Ok(serde_json::to_value(pairs)?) -} + .collect() + } -fn parse_indexed_usize_lists_value(raw: &str) -> Result { - let entries: Vec<(usize, Vec)> = raw + input .split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let entry = entry.trim(); - let (index, values) = entry - .split_once(':') - .ok_or_else(|| anyhow::anyhow!("Invalid entry '{entry}': expected index:values"))?; - Ok(( - index.trim().parse::()?, - if values.trim().is_empty() { - Vec::new() - } else { - util::parse_comma_list(values.trim())? - }, - )) + .map(|dep| { + let parts: Vec<&str> = dep.trim().split('>').collect(); + if parts.len() != 2 { + bail!( + "Invalid dependency format: expected 'lhs>rhs', got '{}'", + dep.trim() + ); + } + let lhs = parse_dependency_side(parts[0])?; + let rhs = parse_dependency_side(parts[1])?; + Ok((lhs, rhs)) }) - .collect::>()?; - Ok(serde_json::to_value(entries)?) -} - -fn parse_string_list_value(raw: &str) -> Result { - let values: Vec = raw - .split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| entry.trim().to_string()) - .collect(); - Ok(serde_json::to_value(values)?) + .collect() } -fn parse_symbol_list_allow_empty(raw: &str) -> Result> { - let raw = raw.trim(); - if raw.is_empty() { - return Ok(Vec::new()); +fn validate_comparative_containment_sets( + family_name: &str, + flag: &str, + universe_size: usize, + sets: &[Vec], +) -> Result<()> { + for (set_index, set) in sets.iter().enumerate() { + for &element in set { + anyhow::ensure!( + element < universe_size, + "{family_name} set {set_index} from {flag} contains element {element} outside universe of size {universe_size}" + ); + } } - raw.split(',') - .map(|value| { - value - .trim() - .parse::() - .context("invalid symbol index") - }) - .collect() + Ok(()) } -fn parse_lcs_strings(raw: &str) -> Result<(Vec>, usize)> { - let segments: Vec<&str> = raw.split(';').map(str::trim).collect(); - let comma_mode = segments.iter().any(|segment| segment.contains(',')); - - if comma_mode { - let strings = segments - .iter() - .map(|segment| parse_symbol_list_allow_empty(segment)) - .collect::>>()?; - let inferred_alphabet_size = strings - .iter() - .flat_map(|string| string.iter()) - .copied() - .max() - .map(|value| value + 1) - .unwrap_or(0); - return Ok((strings, inferred_alphabet_size)); - } +/// Parse `--partition` as semicolon-separated groups of comma-separated arc indices. +/// E.g., "0,1;2,3;4,7;5,6" +fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result>> { + let partition_str = args.partition.as_deref().ok_or_else(|| { + anyhow::anyhow!("MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")") + })?; - let mut encoding = BTreeMap::new(); - let mut next_symbol = 0usize; - let strings = segments - .iter() - .map(|segment| { - segment - .as_bytes() - .iter() - .map(|byte| { - let entry = encoding.entry(*byte).or_insert_with(|| { - let current = next_symbol; - next_symbol += 1; - current - }); - *entry + let partition: Vec> = partition_str + .split(';') + .map(|group| { + group + .trim() + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid partition index: {}", e)) }) - .collect::>() + .collect() }) - .collect::>(); - Ok((strings, next_symbol)) -} + .collect::>()?; -fn parse_bcnf_functional_deps( - raw: &str, - num_attributes: usize, -) -> Result, Vec)>> { - raw.split(';') - .map(|fd_str| { - let parts: Vec<&str> = fd_str.split(':').collect(); - anyhow::ensure!( - parts.len() == 2, - "Each FD must be lhs:rhs, got '{}'", - fd_str - ); - let lhs: Vec = util::parse_comma_list(parts[0])?; - let rhs: Vec = util::parse_comma_list(parts[1])?; - ensure_attribute_indices_in_range( - &lhs, - num_attributes, - &format!("Functional dependency '{fd_str}' lhs"), - )?; - ensure_attribute_indices_in_range( - &rhs, - num_attributes, - &format!("Functional dependency '{fd_str}' rhs"), - )?; - Ok((lhs, rhs)) - }) - .collect() -} + let mut seen = vec![false; num_arcs]; + for group in &partition { + for &arc_index in group { + anyhow::ensure!( + arc_index < num_arcs, + "partition arc index {} out of range for {} arcs", + arc_index, + num_arcs + ); + anyhow::ensure!( + !seen[arc_index], + "partition arc index {} appears more than once", + arc_index + ); + seen[arc_index] = true; + } + } + anyhow::ensure!( + seen.iter().all(|present| *present), + "partition must cover every arc exactly once" + ); -fn parse_cdft_frequency_tables_value( - raw: &str, - context: &CreateContext, -) -> Result> { - let attribute_domains: Vec = serde_json::from_value( - context - .parsed_fields - .get("attribute_domains") - .cloned() - .ok_or_else(|| { - anyhow::anyhow!( - "CDFT frequency table parsing requires prior attribute_domains field" - ) - })?, - ) - .context("Failed to deserialize parsed CDFT attribute domains")?; - let num_objects = context.usize_field("num_objects").ok_or_else(|| { - anyhow::anyhow!("CDFT frequency table parsing requires prior num_objects field") - })?; - parse_cdft_frequency_tables(raw, &attribute_domains, num_objects) + Ok(partition) } -fn parse_cdft_known_values_value(raw: &str, context: &CreateContext) -> Result> { - let attribute_domains: Vec = serde_json::from_value( - context - .parsed_fields - .get("attribute_domains") - .cloned() - .ok_or_else(|| { - anyhow::anyhow!("CDFT known-value parsing requires prior attribute_domains field") - })?, - ) - .context("Failed to deserialize parsed CDFT attribute domains")?; - let num_objects = context.usize_field("num_objects").ok_or_else(|| { - anyhow::anyhow!("CDFT known-value parsing requires prior num_objects field") - })?; - parse_cdft_known_values(Some(raw), num_objects, &attribute_domains) -} +fn parse_bundles(args: &CreateArgs, num_arcs: usize, usage: &str) -> Result>> { + let bundles_str = args + .bundles + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --bundles\n\n{usage}"))?; -fn parse_cvp_bounds_value(raw: Option<&str>, context: &CreateContext) -> Result { - let basis_len = context - .parsed_fields - .get("basis") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .ok_or_else(|| anyhow::anyhow!("CVP bounds parsing requires a prior basis field"))?; + let bundles: Vec> = bundles_str + .split(';') + .map(|bundle| { + let bundle = bundle.trim(); + anyhow::ensure!( + !bundle.is_empty(), + "IntegralFlowBundles does not allow empty bundle entries\n\n{usage}" + ); + bundle + .split(',') + .map(|s| { + s.trim().parse::().with_context(|| { + format!("Invalid bundle arc index `{}`\n\n{usage}", s.trim()) + }) + }) + .collect::>>() + }) + .collect::>()?; - let (lower, upper) = match raw { - Some(raw) => { - let parts: Vec = util::parse_comma_list(raw)?; + let mut seen_overall = vec![false; num_arcs]; + for (bundle_index, bundle) in bundles.iter().enumerate() { + let mut seen_in_bundle = BTreeSet::new(); + for &arc_index in bundle { + anyhow::ensure!( + arc_index < num_arcs, + "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}\n\n{usage}" + ); anyhow::ensure!( - parts.len() == 2, - "--bounds expects \"lower,upper\" (e.g., \"-10,10\")" + seen_in_bundle.insert(arc_index), + "bundle {bundle_index} contains duplicate arc index {arc_index}\n\n{usage}" ); - (parts[0], parts[1]) + seen_overall[arc_index] = true; } - None => (-10, 10), - }; - let bounds = - vec![problemreductions::models::algebraic::VarBounds::bounded(lower, upper); basis_len]; - Ok(serde_json::to_value(bounds)?) -} + } + anyhow::ensure!( + seen_overall.iter().all(|covered| *covered), + "bundles must cover every arc at least once\n\n{usage}" + ); -fn parse_biguint_list_value(raw: &str) -> Result { - let values: Vec = util::parse_biguint_list(raw)? - .into_iter() - .map(|value| value.to_string()) - .collect(); - Ok(serde_json::to_value(values)?) + Ok(bundles) } -fn parse_biguint_value(raw: &str) -> Result { - let value: BigUint = util::parse_decimal_biguint(raw)?; - Ok(serde_json::Value::String(value.to_string())) +fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) -> Result { + let raw_bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}") + })?; + anyhow::ensure!( + raw_bound >= 0, + "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" + ); + i32::try_from(raw_bound).map_err(|_| { + anyhow::anyhow!( + "MultipleChoiceBranching threshold must fit in a 32-bit signed integer, got {raw_bound}" + ) + }) } -fn parse_optional_bool_list_value(raw: &str) -> Result { - let values: Vec> = raw - .split(',') - .map(|entry| { - let entry = entry.trim(); - match entry { - "?" => Ok(None), - _ => Ok(Some(parse_bool_token(entry)?)), +/// Parse `--weights` for set-based problems (i32), defaulting to all 1s. +fn parse_named_set_weights( + weights_str: Option<&str>, + num_sets: usize, + flag: &str, +) -> Result> { + match weights_str { + Some(w) => { + let weights: Vec = util::parse_comma_list(w)?; + if weights.len() != num_sets { + bail!( + "Expected {} values for {} but got {}", + num_sets, + flag, + weights.len() + ); } - }) - .collect::>()?; - Ok(serde_json::to_value(values)?) + Ok(weights) + } + None => Ok(vec![1i32; num_sets]), + } } -fn parse_quantifiers_raw(raw: &str, context: &CreateContext) -> Result> { - let quantifiers: Vec = raw - .split(',') - .map(|entry| match entry.trim().to_lowercase().as_str() { - "e" | "exists" => Ok(Quantifier::Exists), - "a" | "forall" => Ok(Quantifier::ForAll), - other => Err(anyhow::anyhow!( - "Invalid quantifier '{}': expected E/Exists or A/ForAll", - other - )), - }) - .collect::>()?; +fn parse_named_set_weights_f64( + weights_str: Option<&str>, + num_sets: usize, + flag: &str, +) -> Result> { + match weights_str { + Some(w) => { + let weights: Vec = util::parse_comma_list(w)?; + if weights.len() != num_sets { + bail!( + "Expected {} values for {} but got {}", + num_sets, + flag, + weights.len() + ); + } + Ok(weights) + } + None => Ok(vec![1.0f64; num_sets]), + } +} - if let Some(num_vars) = context.usize_field("num_vars") { +fn validate_comparative_containment_i32_weights( + family_name: &str, + flag: &str, + weights: &[i32], +) -> Result<()> { + for (index, weight) in weights.iter().enumerate() { anyhow::ensure!( - quantifiers.len() == num_vars, - "Expected {num_vars} quantifiers but got {}", - quantifiers.len() + *weight > 0, + "{family_name} weights from {flag} must be positive; found {weight} at index {index}" ); } + Ok(()) +} - Ok(quantifiers) +fn validate_comparative_containment_f64_weights( + family_name: &str, + flag: &str, + weights: &[f64], +) -> Result<()> { + for (index, weight) in weights.iter().enumerate() { + anyhow::ensure!( + weight.is_finite() && *weight > 0.0, + "{family_name} weights from {flag} must be finite and positive; found {weight} at index {index}" + ); + } + Ok(()) } -fn parse_json_passthrough_value(raw: &str) -> Result { - serde_json::from_str(raw).context("Invalid JSON input") +/// Parse `--matrix` as semicolon-separated rows of comma-separated bool values (0/1). +/// E.g., "1,0;0,1;1,1" +fn parse_bool_matrix(args: &CreateArgs) -> Result>> { + let matrix_str = args + .matrix + .as_deref() + .ok_or_else(|| anyhow::anyhow!("This problem requires --matrix (e.g., \"1,0;0,1;1,1\")"))?; + parse_bool_rows(matrix_str) } -fn parse_bool_token(raw: &str) -> Result { - match raw.trim() { - "1" | "true" | "TRUE" | "True" => Ok(true), - "0" | "false" | "FALSE" | "False" => Ok(false), - other => bail!("Invalid boolean entry '{other}': expected 0/1 or true/false"), +fn parse_bool_rows(rows_str: &str) -> Result>> { + let matrix: Vec> = rows_str + .split(';') + .map(|row| { + row.trim() + .split(',') + .map(|entry| match entry.trim() { + "1" | "true" => Ok(true), + "0" | "false" => Ok(false), + other => Err(anyhow::anyhow!( + "Invalid boolean entry '{other}': expected 0/1 or true/false" + )), + }) + .collect() + }) + .collect::>()?; + + if let Some(expected_width) = matrix.first().map(Vec::len) { + anyhow::ensure!( + matrix.iter().all(|row| row.len() == expected_width), + "All rows in --matrix must have the same length" + ); } -} -fn parse_simple_graph_value(raw: &str, context: &CreateContext) -> Result { - let raw = raw.trim(); - let num_vertices = context.usize_field("num_vertices").or(context.num_vertices); - let graph = if raw.is_empty() { - let num_vertices = num_vertices.ok_or_else(|| { - anyhow::anyhow!( - "Empty graph string. To create a graph with isolated vertices, provide num_vertices first." - ) - })?; - SimpleGraph::empty(num_vertices) - } else { - let edges = util::parse_edge_pairs(raw)?; - let inferred_num_vertices = edges - .iter() - .flat_map(|&(u, v)| [u, v]) - .max() - .map(|max_vertex| max_vertex + 1) - .unwrap_or(0); - let num_vertices = match num_vertices { - Some(explicit) => { - anyhow::ensure!( - explicit >= inferred_num_vertices, - "num_vertices ({explicit}) is too small for the graph: need at least {inferred_num_vertices}" - ); - explicit - } - None => inferred_num_vertices, - }; - SimpleGraph::new(num_vertices, edges) - }; - Ok(serde_json::to_value(graph)?) + Ok(matrix) } -fn parse_directed_graph_value(raw: &str, context: &CreateContext) -> Result { - let (graph, _) = parse_directed_graph( - raw, - context.usize_field("num_vertices").or(context.num_vertices), - )?; - Ok(serde_json::to_value(graph)?) +fn parse_named_u64_list( + raw: Option<&str>, + problem: &str, + flag: &str, + usage: &str, +) -> Result> { + let raw = raw.ok_or_else(|| anyhow::anyhow!("{problem} requires {flag}\n\n{usage}"))?; + util::parse_comma_list(raw).map_err(|err| anyhow::anyhow!("{err}\n\n{usage}")) } -fn parse_grid_subgraph_value(raw: &str, kings: bool) -> Result { - let positions = util::parse_positions::(raw, "0,0")?; - if kings { - Ok(serde_json::to_value(KingsSubgraph::new(positions))?) - } else { - Ok(serde_json::to_value(TriangularSubgraph::new(positions))?) - } +fn ensure_named_len(len: usize, expected: usize, flag: &str, usage: &str) -> Result<()> { + anyhow::ensure!( + len == expected, + "{flag} must contain exactly {expected} entries\n\n{usage}" + ); + Ok(()) } -fn parse_unit_disk_graph_value(raw: &str, context: &CreateContext) -> Result { - let positions = util::parse_positions::(raw, "0.0,0.0")?; - let radius = context - .f64_field("radius") - .ok_or_else(|| anyhow::anyhow!("UnitDiskGraph parsing requires a prior radius field"))?; - Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) +fn parse_named_bool_rows(rows: Option<&str>, flag: &str, usage: &str) -> Result>> { + let rows = rows.ok_or_else(|| anyhow::anyhow!("TimetableDesign requires {flag}\n\n{usage}"))?; + parse_bool_rows(rows).map_err(|err| { + let message = err.to_string().replace("--matrix", flag); + anyhow::anyhow!("{message}\n\n{usage}") + }) } -fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { - match type_name { - "SimpleGraph" => "edge list: 0-1,1-2,2-3", - "G" => match graph_type { - Some("KingsSubgraph" | "TriangularSubgraph") => "integer positions: \"0,0;1,0;1,1\"", - Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"", - _ => "edge list: 0-1,1-2,2-3", - }, - "Vec<(Vec, Vec)>" => "semicolon-separated dependencies: \"0,1>2;0,2>3\"", - "Vec" => "comma-separated integers: 4,5,3,2,6", - "Vec" => "comma-separated: 1,2,3", - "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", - "Vec" => "comma-separated indices: 0,2,4", - "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { - "comma-separated weighted edges: 0-2:3,1-3:5" - } - "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", - "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", - "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", - "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", - "usize" => "integer", - "u64" => "integer", - "i64" => "integer", - "BigUint" => "nonnegative decimal integer", - "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", - "Vec" => "comma-separated integers: 3,7,1,8", - "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", - _ => "value", +fn parse_timetable_requirements(requirements: Option<&str>, usage: &str) -> Result>> { + let requirements = requirements + .ok_or_else(|| anyhow::anyhow!("TimetableDesign requires --requirements\n\n{usage}"))?; + let matrix: Vec> = requirements + .split(';') + .map(|row| util::parse_comma_list(row.trim())) + .collect::>()?; + + if let Some(expected_width) = matrix.first().map(Vec::len) { + anyhow::ensure!( + matrix.iter().all(|row| row.len() == expected_width), + "All rows in --requirements must have the same length" + ); } + + Ok(matrix) } -fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { - match canonical { - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" => match graph_type { - Some("KingsSubgraph") => "--positions \"0,0;1,0;1,1;0,1\"", - Some("TriangularSubgraph") => "--positions \"0,0;0,1;1,0;1,1\"", - Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5", - _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", - }, - "KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3", - "VertexCover" => "--graph 0-1,1-2,0-2,2-3 --k 2", - "GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5", - "IntegralFlowBundles" => { - "--arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4" - } - "IntegralFlowWithMultipliers" => { - "--arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2" - } - "MinimumCutIntoBoundedSets" => { - "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --source 0 --sink 3 --size-bound 3" - } - "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 --max-weight 6" - } - "HamiltonianPath" => "--graph 0-1,1-2,2-3", - "HamiltonianPathBetweenTwoVertices" => { - "--graph 0-1,0-3,1-2,1-4,2-5,3-4,4-5,2-3 --source-vertex 0 --target-vertex 5" - } - "GraphPartitioning" => "--graph 0-1,1-2,2-3,3-0 --num-partitions 2", - "LongestPath" => { - "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" - } - "UndirectedFlowLowerBounds" => { - "--graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3" - } - "UndirectedTwoCommodityIntegralFlow" => { - "--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1" - }, - "DisjointConnectingPaths" => { - "--graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5" - } - "IntegralFlowHomologousArcs" => { - "--arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\"" - } - "LengthBoundedDisjointPaths" => { - "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 4" - } - "PathConstrainedNetworkFlow" => { - "--arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3" - } - "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", - "BoundedDiameterSpanningTree" => { - "--graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --edge-weights 1,2,1,1,2,1,1 --weight-bound 5 --diameter-bound 3" - } - "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", - "LongestCircuit" => { - "--graph 0-1,1-2,2-3,3-4,4-5,5-0,0-3,1-4,2-5,3-5 --edge-weights 3,2,4,1,5,2,3,2,1,2" - } - "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" - } - "ShortestWeightConstrainedPath" => { - "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8" - } - "SteinerTreeInGraphs" => "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --terminals 0,3", - "BiconnectivityAugmentation" => { - "--graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5" - } - "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" - } - "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", - "NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"", - "QuantifiedBooleanFormulas" => { - "--num-vars 3 --clauses \"1,2;-1,3\" --quantifiers \"E,A,E\"" - } - "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", - "Maximum2Satisfiability" => "--num-vars 4 --clauses \"1,2;1,-2;-1,3;-1,-3;2,4;-3,-4;3,4\"", - "NonTautology" => { - "--num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\"" - } - "OneInThreeSatisfiability" => { - "--num-vars 4 --clauses \"1,2,3;-1,3,4;2,-3,-4\"" - } - "Planar3Satisfiability" => { - "--num-vars 4 --clauses \"1,2,3;-1,2,4;1,-3,4;-2,3,-4\"" - } - "QUBO" => "--matrix \"1,0.5;0.5,2\"", - "QuadraticAssignment" => "--matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"", - "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", - "KColoring" => "--graph 0-1,1-2,2-0 --k 3", - "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", - "MaximumLeafSpanningTree" => "--graph 0-1,0-2,0-3,1-4,2-4,2-5,3-5,4-5,1-3", - "EnsembleComputation" => "--universe-size 4 --subsets \"0,1,2;0,1,3\"", - "RootedTreeStorageAssignment" => { - "--universe-size 5 --subsets \"0,2;1,3;0,4;2,4\" --bound 1" - } - "MinMaxMulticenter" => { - "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" - } - "MinimumSumMulticenter" => { - "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" - } - "BalancedCompleteBipartiteSubgraph" => { - "--left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3" - } - "MaximumAchromaticNumber" => "--graph 0-1,1-2,2-3,3-4,4-5,5-0", - "MaximumDomaticNumber" => "--graph 0-1,1-2,0-2", - "MinimumCoveringByCliques" => "--graph 0-1,1-2,0-2,2-3", - "MinimumIntersectionGraphBasis" => "--graph 0-1,1-2", - "MinimumMaximalMatching" => "--graph 0-1,1-2,2-3,3-4,4-5", - "DegreeConstrainedSpanningTree" => "--graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --k 2", - "MonochromaticTriangle" => "--graph 0-1,0-2,0-3,1-2,1-3,2-3", - "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", - "PartitionIntoCliques" => "--graph 0-1,0-2,1-2,3-4,3-5,4-5 --k 3", - "PartitionIntoForests" => "--graph 0-1,1-2,2-0,3-4,4-5,5-3 --k 2", - "PartitionIntoPerfectMatchings" => "--graph 0-1,2-3,0-2,1-3 --k 2", - "Factoring" => "--target 15 --m 4 --n 4", - "CapacityAssignment" => { - "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12" - } - "ProductionPlanning" => { - "--num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80" - } - "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", - "PreemptiveScheduling" => { - "--lengths 2,1,3,2,1 --num-processors 2 --precedences \"0>2,1>3\"" - } - "SchedulingToMinimizeWeightedCompletionTime" => { - "--lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2" - } - "JobShopScheduling" => { - "--jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1\" --num-processors 2" - } - "MinimumMultiwayCut" => "--graph 0-1,1-2,2-3 --terminals 0,2 --edge-weights 1,1,1", - "ExpectedRetrievalCost" => EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS, - "SequencingWithinIntervals" => "--release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1", - "StaffScheduling" => { - "--schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5" - } - "TimetableDesign" => { - "--num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\"" - } - "SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4", - "MultipleCopyFileAllocation" => { - MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS - } - "AcyclicPartition" => { - "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-weights 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" - } - "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3", - "RootedTreeArrangement" => "--graph 0-1,0-2,1-2,2-3,3-4 --bound 7", - "DirectedTwoCommodityIntegralFlow" => { - "--arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" --capacities 1,1,1,1,1,1,1,1 --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 --requirement-1 1 --requirement-2 1" - } - "MinimumEdgeCostFlow" => { - "--arcs \"0>1,0>2,0>3,1>4,2>4,3>4\" --edge-weights 3,1,2,0,0,0 --capacities 2,2,2,2,2,2 --source 0 --sink 4 --requirement 3" - } - "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", - "DirectedHamiltonianPath" => { - "--arcs \"0>1,0>3,1>3,1>4,2>0,2>4,3>2,3>5,4>5,5>1\" --num-vertices 6" - } - "Kernel" => "--arcs \"0>1,0>2,1>3,2>3,3>4,4>0,4>1\"", - "MinimumGeometricConnectedDominatingSet" => { - "--positions \"0,0;3,0;6,0;9,0;0,3;3,3;6,3;9,3\" --radius 3.5" - } - "MinimumDummyActivitiesPert" => "--arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6", - "FeasibleRegisterAssignment" => { - "--arcs \"0>1,0>2,1>3\" --assignment 0,1,0,0 --k 2 --num-vertices 4" - } - "MinimumFaultDetectionTestSet" => { - "--arcs \"0>2,0>3,1>3,1>4,2>5,3>5,3>6,4>6\" --inputs 0,1 --outputs 5,6 --num-vertices 7" - } - "MinimumWeightAndOrGraph" => { - "--arcs \"0>1,0>2,1>3,1>4,2>5,2>6\" --source 0 --gate-types \"AND,OR,OR,L,L,L,L\" --weights 1,2,3,1,4,2 --num-vertices 7" - } - "MinimumRegisterSufficiencyForLoops" => { - "--loop-length 6 --loop-variables \"0,3;2,3;4,3\"" - } - "RegisterSufficiency" => { - "--arcs \"2>0,2>1,3>1,4>2,4>3,5>0,6>4,6>5\" --bound 3 --num-vertices 7" - } - "StrongConnectivityAugmentation" => { - "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" - } - "MixedChinesePostman" => { - "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4" - } - "RuralPostman" => { - "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2" - } - "StackerCrane" => { - "--arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6" - } - "MultipleChoiceBranching" => { - "--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\" --threshold 10" - } - "AdditionalKey" => "--num-attributes 6 --dependencies \"0,1:2,3;2,3:4,5;4,5:0,1\" --relation-attrs 0,1,2,3,4,5 --known-keys \"0,1;2,3;4,5\"", - "ConsistencyOfDatabaseFrequencyTables" => { - "--num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\"" - } - "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", - "RectilinearPictureCompression" => { - "--matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --bound 2" - } - "SequencingToMinimizeWeightedTardiness" => { - "--lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - } - "IntegerKnapsack" => "--sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15", - "SubsetProduct" => "--sizes 2,3,5,7,6,10 --target 210", - "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", - "MinimumAxiomSet" => { - "--n 8 --true-sentences 0,1,2,3,4,5,6,7 --implications \"0>2;0>3;1>4;1>5;2,4>6;3,5>7;6,7>0;6,7>1\"" - } - "IntegerExpressionMembership" => { - "--expression '{\"Sum\":[{\"Sum\":[{\"Union\":[{\"Atom\":1},{\"Atom\":4}]},{\"Union\":[{\"Atom\":3},{\"Atom\":6}]}]},{\"Union\":[{\"Atom\":2},{\"Atom\":5}]}]}' --target 12" - } - "NonLivenessFreePetriNet" => { - "--n 4 --m 3 --arcs \"0>0,1>1,2>2\" --output-arcs \"0>1,1>2,2>3\" --initial-marking 1,0,0,0" - } - "Betweenness" => "--n 5 --sets \"0,1,2;2,3,4;0,2,4;1,3,4\"", - "CyclicOrdering" => "--n 5 --sets \"0,1,2;2,3,0;1,3,4\"", - "Numerical3DimensionalMatching" => "--w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15", - "ThreePartition" => "--sizes 4,5,6,4,6,5 --bound 15", - "DynamicStorageAllocation" => "--release-times 0,0,1,2,3 --deadlines 3,2,4,5,5 --sizes 2,3,1,3,2 --capacity 6", - "KthLargestMTuple" => "--sets \"2,5,8;3,6;1,4,7\" --k 14 --bound 12", - "AlgebraicEquationsOverGF2" => "--num-vars 3 --equations \"0,1:2;1,2:0:;0:1:2:\"", - "QuadraticCongruences" => "--coeff-a 4 --coeff-b 15 --coeff-c 10", - "QuadraticDiophantineEquations" => "--coeff-a 3 --coeff-b 5 --coeff-c 53", - "SimultaneousIncongruences" => "--pairs \"2,2;1,3;2,5;3,7\"", - "BoyceCoddNormalFormViolation" => { - "--n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - } - "Clustering" => { - "--distance-matrix \"0,1,1,3;1,0,1,3;1,1,0,3;3,3,3,0\" --k 2 --diameter-bound 1" - } - "SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3", - "ComparativeContainment" => { - "--universe-size 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6" - } - "SetBasis" => "--universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3", - "SetSplitting" => "--universe-size 6 --subsets \"0,1,2;2,3,4;0,4,5;1,3,5\"", - "LongestCommonSubsequence" => { - "--strings \"010110;100101;001011\" --alphabet-size 2" - } - "GroupingBySwapping" => "--string \"0,1,2,0,1,2\" --bound 5", - "MinimumExternalMacroDataCompression" | "MinimumInternalMacroDataCompression" => { - "--string \"0,1,0,1\" --pointer-cost 2 --alphabet-size 2" - } - "MinimumCardinalityKey" => { - "--num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\"" - } - "PrimeAttributeName" => { - "--universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" - } - "TwoDimensionalConsecutiveSets" => { - "--alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"" - } - "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\"", - "ConsecutiveBlockMinimization" => "--matrix '[[true,false,true],[false,true,true]]' --bound-k 2", - "ConsecutiveOnesMatrixAugmentation" => { - "--matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" - } - "SparseMatrixCompression" => "--matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2", - "MaximumLikelihoodRanking" => "--matrix \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"", - "MinimumMatrixCover" => "--matrix \"0,3,1,0;3,0,0,2;1,0,0,4;0,2,4,0\"", - "MinimumMatrixDomination" => "--matrix \"0,1,0;1,0,1;0,1,0\"", - "MinimumWeightDecoding" => { - "--matrix '[[true,false,true,true],[false,true,true,false],[true,true,false,true]]' --rhs 'true,true,false'" - } - "MinimumWeightSolutionToLinearEquations" => { - "--matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'" - } - "ConjunctiveBooleanQuery" => { - "--domain-size 6 --relations \"2:0,3|1,3|2,4;3:0,1,5|1,2,5\" --conjuncts-spec \"0:v0,c3;0:v1,c3;1:v0,v1,c5\"" - } - "ConjunctiveQueryFoldability" => "(use --example ConjunctiveQueryFoldability)", - "EquilibriumPoint" => "(use --example EquilibriumPoint)", - "SequencingToMinimizeMaximumCumulativeCost" => { - "--costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\"" - } - "StringToStringCorrection" => { - "--source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2" - } - "FeasibleBasisExtension" => { - "--matrix '[[1,0,1,2,-1,0],[0,1,0,1,1,2],[0,0,1,1,0,1]]' --rhs '7,5,3' --required-columns '0,1'" - } - "MinimumCodeGenerationParallelAssignments" => { - "--num-variables 4 --assignments \"0:1,2;1:0;2:3;3:1,2\"" - } - "MinimumDecisionTree" => { - "--test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3" - } - "MinimumDisjunctiveNormalForm" => { - "--num-vars 3 --truth-table 0,1,1,1,1,1,1,0" - } - "SquareTiling" => { - "--num-colors 3 --tiles \"0,1,2,0;0,0,2,1;2,1,0,0;2,0,0,1\" --grid-size 2" - } - _ => "", - } -} - -fn uses_edge_weights_flag(canonical: &str) -> bool { - matches!( - canonical, - "BottleneckTravelingSalesman" - | "BoundedDiameterSpanningTree" - | "KthBestSpanningTree" - | "LongestCircuit" - | "MaxCut" - | "MaximumMatching" - | "MixedChinesePostman" - | "RuralPostman" - | "TravelingSalesman" - ) -} - -fn uses_edge_weights_flag_for_edge_lengths(canonical: &str) -> bool { - matches!( - canonical, - "LongestCircuit" | "MinMaxMulticenter" | "MinimumSumMulticenter" - ) -} +fn validate_timetable_design_args( + num_periods: usize, + num_craftsmen: usize, + num_tasks: usize, + craftsman_avail: &[Vec], + task_avail: &[Vec], + requirements: &[Vec], + usage: &str, +) -> Result<()> { + anyhow::ensure!( + craftsman_avail.len() == num_craftsmen, + "craftsman availability row count ({}) must equal num_craftsmen ({})\n\n{}", + craftsman_avail.len(), + num_craftsmen, + usage + ); + anyhow::ensure!( + task_avail.len() == num_tasks, + "task availability row count ({}) must equal num_tasks ({})\n\n{}", + task_avail.len(), + num_tasks, + usage + ); + anyhow::ensure!( + requirements.len() == num_craftsmen, + "requirements row count ({}) must equal num_craftsmen ({})\n\n{}", + requirements.len(), + num_craftsmen, + usage + ); -fn help_flag_name(canonical: &str, field_name: &str) -> String { - // Problem-specific overrides first - match (canonical, field_name) { - ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), - ("BoundedComponentSpanningForest", "max_weight") => return "max-weight".to_string(), - ("BoyceCoddNormalFormViolation", "num_attributes") => return "n".to_string(), - ("BoyceCoddNormalFormViolation", "functional_deps") => return "sets".to_string(), - ("BoyceCoddNormalFormViolation", "target_subset") => return "target".to_string(), - ("CapacityAssignment", "cost") => return "cost-matrix".to_string(), - ("CapacityAssignment", "delay") => return "delay-matrix".to_string(), - ("FlowShopScheduling", "num_processors") - | ("JobShopScheduling", "num_processors") - | ("OpenShopScheduling", "num_machines") - | ("SchedulingWithIndividualDeadlines", "num_processors") => { - return "num-processors/--m".to_string(); - } - ("JobShopScheduling", "jobs") => return "jobs".to_string(), - ("LengthBoundedDisjointPaths", "max_length") => return "max-length".to_string(), - ("ConsecutiveBlockMinimization", "bound") => return "bound-k".to_string(), - ("GroupingBySwapping", "budget") => return "bound".to_string(), - ("RectilinearPictureCompression", "bound") => return "bound".to_string(), - ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), - ("PrimeAttributeName", "dependencies") => return "dependencies".to_string(), - ("PrimeAttributeName", "query_attribute") => return "query-attribute".to_string(), - ("ClosestVectorProblem", "target") => return "target-vec".to_string(), - ("ConjunctiveBooleanQuery", "conjuncts") => return "conjuncts-spec".to_string(), - ("MixedChinesePostman", "arc_weights") => return "arc-weights".to_string(), - ("ConsecutiveOnesMatrixAugmentation", "bound") => return "bound".to_string(), - ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), - ("SparseMatrixCompression", "bound_k") => return "bound-k".to_string(), - ("MinimumCodeGenerationParallelAssignments", "num_variables") => { - return "num-variables".to_string(); - } - ("MinimumCodeGenerationParallelAssignments", "assignments") => { - return "assignments".to_string(); - } - ("StackerCrane", "edges") => return "graph".to_string(), - ("StackerCrane", "arc_lengths") => return "arc-lengths".to_string(), - ("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(), - ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), - ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), - _ => {} - } - // Edge-weight problems use --edge-weights instead of --weights - if field_name == "weights" && uses_edge_weights_flag(canonical) { - return "edge-weights".to_string(); - } - if field_name == "edge_lengths" && uses_edge_weights_flag_for_edge_lengths(canonical) { - return "edge-weights".to_string(); + for (index, row) in craftsman_avail.iter().enumerate() { + anyhow::ensure!( + row.len() == num_periods, + "craftsman availability row {} has {} periods, expected {}\n\n{}", + index, + row.len(), + num_periods, + usage + ); } - // General field-name overrides (previously in cli_flag_name) - match field_name { - "universe_size" => "universe-size".to_string(), - "collection" | "subsets" => "subsets".to_string(), - "left_size" => "left".to_string(), - "right_size" => "right".to_string(), - "edges" => "biedges".to_string(), - "vertex_weights" => "weights".to_string(), - "potential_weights" => "potential-weights".to_string(), - "num_tasks" => "num-tasks".to_string(), - "precedences" => "precedences".to_string(), - "threshold" => "threshold".to_string(), - "lengths" => "lengths".to_string(), - _ => field_name.replace('_', "-"), + for (index, row) in task_avail.iter().enumerate() { + anyhow::ensure!( + row.len() == num_periods, + "task availability row {} has {} periods, expected {}\n\n{}", + index, + row.len(), + num_periods, + usage + ); } -} - -fn reject_vertex_weights_for_edge_weight_problem( - args: &CreateArgs, - canonical: &str, - graph_type: Option<&str>, -) -> Result<()> { - if args.weights.is_some() && uses_edge_weights_flag(canonical) { - bail!( - "{canonical} uses --edge-weights, not --weights.\n\n\ - Usage: pred create {} {}", - match graph_type { - Some(g) => format!("{canonical}/{g}"), - None => canonical.to_string(), - }, - example_for(canonical, graph_type) + for (index, row) in requirements.iter().enumerate() { + anyhow::ensure!( + row.len() == num_tasks, + "requirements row {} has {} tasks, expected {}\n\n{}", + index, + row.len(), + num_tasks, + usage ); } + Ok(()) } -fn help_flag_hint( - canonical: &str, - field_name: &str, - type_name: &str, - graph_type: Option<&str>, -) -> &'static str { - match (canonical, field_name) { - ("BoundedComponentSpanningForest", "max_weight") => "integer", - ("SequencingWithinIntervals", "release_times") => "comma-separated integers: 0,0,5", - ("DynamicStorageAllocation", "release_times") => "comma-separated arrival times: 0,0,1,2,3", - ("DynamicStorageAllocation", "deadlines") => "comma-separated departure times: 3,2,4,5,5", - ("DynamicStorageAllocation", "sizes") => "comma-separated item sizes: 2,3,1,3,2", - ("DynamicStorageAllocation", "capacity") => "memory size D: 6", - ("DisjointConnectingPaths", "terminal_pairs") => "comma-separated pairs: 0-3,2-5", - ("PrimeAttributeName", "dependencies") => { - "semicolon-separated dependencies: \"0,1>2,3;2,3>0,1\"" - } - ("LongestCommonSubsequence", "strings") => { - "raw strings: \"ABAC;BACA\" or symbol lists: \"0,1,0;1,0,1\"" - } - ("GroupingBySwapping", "string") => "symbol list: \"0,1,2,0,1,2\"", - ("MinimumExternalMacroDataCompression", "string") - | ("MinimumInternalMacroDataCompression", "string") => "symbol list: \"0,1,0,1\"", - ("MinimumExternalMacroDataCompression", "pointer_cost") - | ("MinimumInternalMacroDataCompression", "pointer_cost") => "positive integer: 2", - ("MinimumAxiomSet", "num_sentences") => "total number of sentences: 8", - ("MinimumAxiomSet", "true_sentences") => "comma-separated indices: 0,1,2,3,4,5,6,7", - ("MinimumAxiomSet", "implications") => "semicolon-separated rules: \"0>2;0>3;1>4;2,4>6\"", - ("ShortestCommonSupersequence", "strings") => "symbol lists: \"0,1,2;1,2,0\"", - ("MultipleChoiceBranching", "partition") => "semicolon-separated groups: \"0,1;2,3\"", - ("IntegralFlowHomologousArcs", "homologous_pairs") => { - "semicolon-separated arc-index equalities: \"2=5;4=3\"" - } - ("ConsistencyOfDatabaseFrequencyTables", "attribute_domains") => { - "comma-separated domain sizes: 2,3,2" - } - ("ConsistencyOfDatabaseFrequencyTables", "frequency_tables") => { - "semicolon-separated tables: \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\"" - } - ("ConsistencyOfDatabaseFrequencyTables", "known_values") => { - "semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\"" - } - ("IntegralFlowBundles", "bundles") => "semicolon-separated groups: \"0,1;2,5;3,4\"", - ("IntegralFlowBundles", "bundle_capacities") => "comma-separated capacities: 1,1,1", - ("PathConstrainedNetworkFlow", "paths") => { - "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" - } - ("ConsecutiveBlockMinimization", "matrix") => { - "JSON 2D bool array: '[[true,false,true],[false,true,true]]'" - } - ("ConsecutiveOnesMatrixAugmentation", "matrix") => { - "semicolon-separated 0/1 rows: \"1,0;0,1\"" - } - ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("MaximumLikelihoodRanking", "matrix") => { - "semicolon-separated i32 rows: \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"" - } - ("MinimumMatrixCover", "matrix") => "semicolon-separated i64 rows: \"0,3,1;3,0,2;1,2,0\"", - ("MinimumMatrixDomination", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", - ("MinimumWeightDecoding", "matrix") => "JSON 2D bool array: '[[true,false],[false,true]]'", - ("MinimumWeightDecoding", "target") => "comma-separated booleans: \"true,true,false\"", - ("MinimumWeightSolutionToLinearEquations", "matrix") => { - "JSON 2D integer array: '[[1,2,3],[4,5,6]]'" - } - ("MinimumWeightSolutionToLinearEquations", "rhs") => "comma-separated integers: \"5,4\"", - ("FeasibleBasisExtension", "matrix") => "JSON 2D integer array: '[[1,0,1],[0,1,0]]'", - ("FeasibleBasisExtension", "rhs") => "comma-separated integers: \"7,5,3\"", - ("FeasibleBasisExtension", "required_columns") => "comma-separated column indices: \"0,1\"", - ("MinimumCodeGenerationParallelAssignments", "assignments") => { - "semicolon-separated target:reads entries: \"0:1,2;1:0;2:3;3:1,2\"" - } - ("NonTautology", "disjuncts") => "semicolon-separated disjuncts: \"1,2,3;-1,-2,-3\"", - ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { - "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" - } - ("TimetableDesign", "requirements") => "semicolon-separated rows: \"1,0,1;0,1,0\"", - _ => type_format_hint(type_name, graph_type), - } -} +/// Parse `--matrix` as semicolon-separated rows of comma-separated f64 values. +/// E.g., "1,0.5;0.5,2" +fn parse_matrix(args: &CreateArgs) -> Result>> { + let matrix_str = args + .matrix + .as_deref() + .ok_or_else(|| anyhow::anyhow!("QUBO requires --matrix (e.g., \"1,0.5;0.5,2\")"))?; -fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> Result { - usize::try_from(bound) - .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) + matrix_str + .split(';') + .map(|row| { + row.trim() + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid matrix value: {}", e)) + }) + .collect() + }) + .collect() } -fn validate_prescribed_paths_against_graph( - graph: &DirectedGraph, - paths: &[Vec], - source: usize, - sink: usize, - usage: &str, -) -> Result<()> { - let arcs = graph.arcs(); - for path in paths { - anyhow::ensure!( - !path.is_empty(), - "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" - ); - let mut visited_vertices = BTreeSet::from([source]); - let mut current = source; - for &arc_index in path { - let &(tail, head) = arcs.get(arc_index).ok_or_else(|| { - anyhow::anyhow!( - "Path arc index {arc_index} out of bounds for {} arcs\n\n{usage}", - arcs.len() - ) - })?; - anyhow::ensure!( - tail == current, - "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}\n\n{usage}" - ); +fn parse_u64_matrix_rows(matrix_str: &str, matrix_name: &str) -> Result>> { + matrix_str + .split(';') + .enumerate() + .map(|(row_index, row)| { + let row = row.trim(); anyhow::ensure!( - visited_vertices.insert(head), - "prescribed path repeats vertex {head}, so it is not a simple path\n\n{usage}" + !row.is_empty(), + "{matrix_name} row {row_index} must not be empty" ); - current = head; - } - anyhow::ensure!( - current == sink, - "prescribed path must end at sink {sink}, ended at {current}\n\n{usage}" - ); - } - Ok(()) + row.split(',') + .map(|value| { + value.trim().parse::().map_err(|error| { + anyhow::anyhow!( + "Invalid {matrix_name} row {row_index} value {:?}: {}", + value.trim(), + error + ) + }) + }) + .collect() + }) + .collect() } -fn validate_schema_driven_semantics( - args: &CreateArgs, - canonical: &str, - resolved_variant: &BTreeMap, - _data: &serde_json::Value, -) -> Result<()> { - match canonical { - "BalancedCompleteBipartiteSubgraph" => { - let usage = "pred create BalancedCompleteBipartiteSubgraph --left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3"; - let _ = parse_bipartite_problem_input( - args, - "BalancedCompleteBipartiteSubgraph", - "balanced biclique size", - usage, - )?; - } - "BiconnectivityAugmentation" => { - let usage = "Usage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let potential_edges = parse_potential_edges(args)?; - validate_potential_edges(&graph, &potential_edges)?; - let _ = parse_budget(args)?; - } - "BoundedComponentSpanningForest" => { - 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 --max-weight 6"; - let (_, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") - })?; - let weights = parse_vertex_weights(args, n)?; - if weights.iter().any(|&weight| weight < 0) { - bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}"); - } - let max_components = args.k.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}") +/// Parse `--quantifiers` as comma-separated quantifier labels (E/A or Exists/ForAll). +/// E.g., "E,A,E" or "Exists,ForAll,Exists" +/// Parse a semicolon-separated matrix of i64 values. +/// E.g., "0,5;5,0" +fn parse_potential_edges(args: &CreateArgs) -> Result> { + let edges_str = args.potential_edges.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "BiconnectivityAugmentation requires --potential-weights (e.g., 0-2:3,1-3:5)" + ) + })?; + + edges_str + .split(',') + .map(|entry| { + let entry = entry.trim(); + let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") })?; - if max_components == 0 { - bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); - } - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") + let (u_str, v_str) = edge_part.split_once('-').ok_or_else(|| { + anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") })?; - if bound_raw <= 0 { - bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); + let u = u_str.trim().parse::()?; + let v = v_str.trim().parse::()?; + if u == v { + bail!("Self-loop detected in potential edge {u}-{v}"); } - let _ = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - } - "CapacityAssignment" => { - let usage = "Usage: pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; - let capacities_str = args.capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "CapacityAssignment requires --capacities, --cost-matrix, --delay-matrix, and --delay-budget\n\n{usage}" - ) - })?; - let cost_matrix_str = args.cost_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --cost-matrix\n\n{usage}") - })?; - let delay_matrix_str = args.delay_matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --delay-matrix\n\n{usage}") - })?; - let _ = args.delay_budget.ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --delay-budget\n\n{usage}") - })?; + let weight = weight_part.trim().parse::()?; + Ok((u, v, weight)) + }) + .collect() +} - let capacities: Vec = util::parse_comma_list(capacities_str)?; - anyhow::ensure!( - !capacities.is_empty(), - "CapacityAssignment requires at least one capacity value\n\n{usage}" +fn validate_potential_edges( + graph: &SimpleGraph, + potential_edges: &[(usize, usize, i32)], +) -> Result<()> { + let num_vertices = graph.num_vertices(); + let mut seen_potential_edges = BTreeSet::new(); + for &(u, v, _) in potential_edges { + if u >= num_vertices || v >= num_vertices { + bail!( + "Potential edge {u}-{v} references a vertex outside the graph (num_vertices = {num_vertices})" ); - anyhow::ensure!( - capacities.iter().all(|&capacity| capacity > 0), - "CapacityAssignment capacities must be positive\n\n{usage}" + } + let edge = if u <= v { (u, v) } else { (v, u) }; + if graph.has_edge(edge.0, edge.1) { + bail!( + "Potential edge {}-{} already exists in the graph", + edge.0, + edge.1 ); - anyhow::ensure!( - capacities.windows(2).all(|w| w[0] < w[1]), - "CapacityAssignment capacities must be strictly increasing\n\n{usage}" + } + if !seen_potential_edges.insert(edge) { + bail!( + "Duplicate potential edge {}-{} is not allowed", + edge.0, + edge.1 ); + } + } + Ok(()) +} - let cost = parse_u64_matrix_rows(cost_matrix_str, "cost")?; - let delay = parse_u64_matrix_rows(delay_matrix_str, "delay")?; - anyhow::ensure!( - cost.len() == delay.len(), - "cost matrix row count ({}) must match delay matrix row count ({})\n\n{usage}", - cost.len(), - delay.len() - ); +fn parse_budget(args: &CreateArgs) -> Result { + let budget = args + .budget + .as_deref() + .ok_or_else(|| anyhow::anyhow!("BiconnectivityAugmentation requires --budget (e.g., 5)"))?; + budget + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid budget '{budget}': {e}")) +} - for (index, row) in cost.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "cost row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] <= w[1]), - "cost row {} must be non-decreasing\n\n{usage}", - index - ); - } - for (index, row) in delay.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "delay row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] >= w[1]), - "delay row {} must be non-increasing\n\n{usage}", - index +/// Parse `--arcs` as directed arc pairs and build a `DirectedGraph`. +/// +/// Returns `(graph, num_arcs)`. Infers vertex count from arc endpoints +/// unless `num_vertices` is provided (which must be >= inferred count). +/// E.g., "0>1,1>2,2>0" +fn parse_directed_graph( + arcs_str: &str, + num_vertices: Option, +) -> Result<(DirectedGraph, usize)> { + let arcs: Vec<(usize, usize)> = arcs_str + .split(',') + .map(|pair| { + let parts: Vec<&str> = pair.trim().split('>').collect(); + if parts.len() != 2 { + bail!( + "Invalid arc '{}': expected format u>v (e.g., 0>1)", + pair.trim() ); } - } - "BoyceCoddNormalFormViolation" => { - let n = args.n.ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --n, --sets, and --target\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let sets_str = args.sets.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --sets (functional deps as lhs:rhs;...)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let target_str = args.target.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let _ = parse_bcnf_functional_deps(sets_str, n)?; - let target: Vec = util::parse_comma_list(target_str)?; - ensure_attribute_indices_in_range(&target, n, "Target subset")?; - } - "ClosestVectorProblem" => { - let basis_str = args.basis.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "CVP requires --basis, --target-vec\n\n\ - Usage: pred create CVP --basis \"1,0;0,1\" --target-vec \"0.5,0.5\"" - ) - })?; - let target_str = args - .target_vec - .as_deref() - .ok_or_else(|| anyhow::anyhow!("CVP requires --target-vec (e.g., \"0.5,0.5\")"))?; - let basis: Vec> = basis_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - let target: Vec = util::parse_comma_list(target_str)?; - let n = basis.len(); - let bounds = serde_json::from_value(parse_cvp_bounds_value( - args.bounds.as_deref(), - &CreateContext { - num_vertices: None, - num_edges: None, - num_arcs: None, - parsed_fields: BTreeMap::from([( - "basis".to_string(), - serde_json::json!(vec![serde_json::json!([0]); n]), - )]), - }, - )?)?; - let _ = ClosestVectorProblem::new(basis, target, bounds); - } - "ConsecutiveOnesMatrixAugmentation" => { - let matrix = parse_bool_matrix(args)?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ - Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" - ) - })?; - ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) - .map_err(anyhow::Error::msg)?; - } - "ConsecutiveBlockMinimization" => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix_str = args.matrix.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - ConsecutiveBlockMinimization::try_new(matrix, bound) - .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?; - } - "ComparativeContainment" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ComparativeContainment requires --universe, --r-sets, and --s-sets\n\n\ - Usage: pred create ComparativeContainment --universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]" - ) - })?; - let r_sets = parse_named_sets(args.r_sets.as_deref(), "--r-sets")?; - let s_sets = parse_named_sets(args.s_sets.as_deref(), "--s-sets")?; - validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?; - validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?; - match resolved_variant.get("weight").map(|value| value.as_str()) { - Some("One") => { - let r_weights = parse_named_set_weights( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - let s_weights = parse_named_set_weights( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - anyhow::ensure!( - r_weights.iter().all(|&w| w == 1) && s_weights.iter().all(|&w| w == 1), - "Non-unit weights are not supported for ComparativeContainment/One.\n\n\ - Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances." - ); - } - Some("f64") => { - let r_weights = parse_named_set_weights_f64( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights_f64( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?; - } - Some("i32") | None => { - let r_weights = parse_named_set_weights( - args.r_weights.as_deref(), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights( - args.s_weights.as_deref(), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?; - } - Some(other) => bail!( - "Unsupported ComparativeContainment weight variant: {}", - other - ), - } - } - "DisjointConnectingPaths" => { - let usage = - "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_terminal_pairs(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "ExactCoverBy3Sets" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "ExactCoverBy3Sets requires --universe and --sets\n\n\ - Usage: pred create X3C --universe 6 --sets \"0,1,2;3,4,5\"" - ) - })?; - if universe % 3 != 0 { - bail!("Universe size must be divisible by 3, got {}", universe); - } - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Subset {} has {} elements, but X3C requires exactly 3 elements per subset", - i, - set.len() - ); - } - if set[0] == set[1] || set[0] == set[2] || set[1] == set[2] { - bail!("Subset {} contains duplicate elements: {:?}", i, set); - } - for &elem in set { - if elem >= universe { - bail!( - "Subset {} contains element {} which is outside universe of size {}", - i, - elem, - universe - ); - } - } - } - } - "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)?; - anyhow::ensure!( - source != sink, - "GeneralizedHex requires distinct --source and --sink\n\n{usage}" - ); - } - "GroupingBySwapping" => { - let usage = - "Usage: pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 [--alphabet-size 3]"; - let string_str = args.string.as_deref().ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound( - args.bound.ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --bound\n\n{usage}") - })?, - "GroupingBySwapping", - usage, - )?; - let string = parse_symbol_list_allow_empty(string_str)?; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", - alphabet_size, - inferred - ); - anyhow::ensure!( - alphabet_size > 0 || string.is_empty(), - "GroupingBySwapping requires a positive alphabet for non-empty strings.\n\n{usage}" - ); + let u: usize = parts[0].parse()?; + let v: usize = parts[1].parse()?; + Ok((u, v)) + }) + .collect::>>()?; + let inferred_num_v = arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|m| m + 1) + .unwrap_or(0); + let num_v = match num_vertices { + Some(user_num_v) => { anyhow::ensure!( - !string.is_empty() || bound == 0, - "GroupingBySwapping requires --bound 0 when --string is empty.\n\n{usage}" + user_num_v >= inferred_num_v, + "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", + user_num_v, + inferred_num_v, + inferred_num_v.saturating_sub(1), ); + user_num_v } - "IntegralFlowBundles" => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - let _ = parse_bundle_capacities(args, bundles.len(), usage)?; - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") - })?; - let sink = args - .sink - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; - let _ = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, graph.num_vertices(), usage)?; - validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; + None => inferred_num_v, + }; + let num_arcs = arcs.len(); + Ok((DirectedGraph::new(num_v, arcs), num_arcs)) +} + +fn parse_prescribed_paths( + args: &CreateArgs, + num_arcs: usize, + usage: &str, +) -> Result>> { + let paths_str = args + .paths + .as_deref() + .ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?; + + paths_str + .split(';') + .map(|path_str| { + let trimmed = path_str.trim(); anyhow::ensure!( - source != sink, - "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" + !trimmed.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" ); - } - "IntegralFlowHomologousArcs" => { - let usage = "Usage: pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\""; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(ref s) = args.capacities { - s.split(',') - .map(|token| { - let trimmed = token.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()? - } else { - vec![1; num_arcs] - }; + let path: Vec = util::parse_comma_list(trimmed)?; anyhow::ensure!( - capacities.len() == num_arcs, - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage + !path.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" ); - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); + for &arc_idx in &path { anyhow::ensure!( - fits, - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage + arc_idx < num_arcs, + "Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}" ); } - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") - })?; - let _ = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - let homologous_pairs = - parse_homologous_pairs(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - for &(a, b) in &homologous_pairs { - anyhow::ensure!( - a < num_arcs && b < num_arcs, - "homologous pair ({}, {}) references arc >= num_arcs ({})\n\n{}", - a, - b, + Ok(path) + }) + .collect() +} + +fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result { + let (undirected_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?; + let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices)) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + Ok(MixedGraph::new( + num_vertices, + directed_graph.arcs(), + undirected_graph.edges(), + )) +} + +/// Parse `--weights` as arc weights (i32), defaulting to all 1s. +fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.weights { + Some(w) => { + let weights: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if weights.len() != num_arcs { + bail!( + "Expected {} arc weights but got {}", num_arcs, - usage + weights.len() ); } + Ok(weights) } - "IntegralFlowWithMultipliers" => { - let usage = "Usage: pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities_str = args.capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --capacities\n\n{usage}") - })?; - let capacities: Vec = util::parse_comma_list(capacities_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if capacities.len() != num_arcs { - bail!( - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage - ); + None => Ok(vec![1i32; num_arcs]), + } +} + +/// Parse `--arc-weights` / `--arc-lengths` as per-arc costs (i32), defaulting to all 1s. +fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.arc_costs { + Some(costs) => { + let parsed: Vec = costs + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if parsed.len() != num_arcs { + bail!("Expected {} arc costs but got {}", num_arcs, parsed.len()); } - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage - ); - } - } - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers 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!( - "IntegralFlowWithMultipliers requires distinct --source and --sink\n\n{}", - usage - ); - } - let multipliers_str = args.multipliers.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --multipliers\n\n{usage}") - })?; - let multipliers: Vec = util::parse_comma_list(multipliers_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if multipliers.len() != num_vertices { - bail!( - "Expected {} multipliers but got {}\n\n{}", - num_vertices, - multipliers.len(), - usage - ); - } - if multipliers - .iter() - .enumerate() - .any(|(vertex, &multiplier)| vertex != source && vertex != sink && multiplier == 0) - { - bail!("non-terminal multipliers must be positive\n\n{usage}"); - } - let _ = args.requirement.ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") - })?; + Ok(parsed) } - "JobShopScheduling" => { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let job_tasks = args - .job_tasks - .as_deref() - .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; - let jobs = parse_job_shop_jobs(job_tasks)?; - let inferred_processors = jobs - .iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1); - let num_processors = resolve_processor_count_flags( - "JobShopScheduling", - usage, - args.num_processors, - args.m, - )? - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - anyhow::ensure!( - num_processors > 0, - "JobShopScheduling requires --num-processors > 0\n\n{usage}" - ); - for (job_index, job) in jobs.iter().enumerate() { - for (task_index, &(processor, _)) in job.iter().enumerate() { - anyhow::ensure!( - processor < num_processors, - "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" - ); + None => Ok(vec![1i32; num_arcs]), + } +} + +/// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. +/// Handle `pred create --random ...` +fn create_random( + args: &CreateArgs, + canonical: &str, + resolved_variant: &BTreeMap, + out: &OutputConfig, +) -> Result<()> { + let num_vertices = args.num_vertices.ok_or_else(|| { + anyhow::anyhow!( + "--random requires --num-vertices\n\n\ + Usage: pred create {} --random --num-vertices 10 [--edge-prob 0.3] [--seed 42]", + canonical + ) + })?; + + let graph_type = resolved_graph_type(resolved_variant); + + let (data, variant) = match canonical { + // Graph problems with vertex weights + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" + | "MinimumDominatingSet" + | "MaximalIS" => { + let weights = vec![1i32; num_vertices]; + match graph_type { + "KingsSubgraph" => { + let positions = util::create_random_int_positions(num_vertices, args.seed); + let graph = KingsSubgraph::new(positions); + ( + ser_vertex_weight_problem_with(canonical, graph, weights)?, + resolved_variant.clone(), + ) + } + "TriangularSubgraph" => { + let positions = util::create_random_int_positions(num_vertices, args.seed); + let graph = TriangularSubgraph::new(positions); + ( + ser_vertex_weight_problem_with(canonical, graph, weights)?, + resolved_variant.clone(), + ) + } + "UnitDiskGraph" => { + let radius = args.radius.unwrap_or(1.0); + let positions = util::create_random_float_positions(num_vertices, args.seed); + let graph = UnitDiskGraph::new(positions, radius); + ( + ser_vertex_weight_problem_with(canonical, graph, weights)?, + resolved_variant.clone(), + ) } - for (task_index, pair) in job.windows(2).enumerate() { - anyhow::ensure!( - pair[0].0 != pair[1].0, - "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", - task_index + 1 - ); + _ => { + 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 variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + let data = ser_vertex_weight_problem_with(canonical, graph, weights)?; + (data, variant) } } } + "KClique" => { - let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; - } - "KColoring" => { - let usage = "Usage: pred create KColoring --graph 0-1,1-2,2-0 --k 3"; - let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = util::validate_k_param(&resolved_variant, args.k, None, "KColoring") - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "KthBestSpanningTree" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let usage = - "Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_edge_weights(args, graph.num_edges())?; - let _ = util::validate_k_param(&resolved_variant, args.k, None, "KthBestSpanningTree") - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args - .bound - .ok_or_else(|| anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}"))? - as i32; - } - "LengthBoundedDisjointPaths" => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let _ = validate_length_bounded_disjoint_paths_args( - graph.num_vertices(), - source, - sink, - bound, - Some(usage), - )?; - } - "LongestCommonSubsequence" => { - let usage = - "Usage: pred create LCS --strings \"010110;100101;001011\" [--alphabet-size 2]"; - let strings_str = args.strings.as_deref().ok_or_else(|| { - anyhow::anyhow!("LongestCommonSubsequence requires --strings\n\n{usage}") - })?; - let (strings, inferred_alphabet_size) = parse_lcs_strings(strings_str)?; - let alphabet_size = args.alphabet_size.unwrap_or(inferred_alphabet_size); - anyhow::ensure!( - alphabet_size >= inferred_alphabet_size, - "--alphabet-size {} is smaller than the inferred alphabet size ({})", - alphabet_size, - inferred_alphabet_size - ); - anyhow::ensure!( - strings.iter().any(|string| !string.is_empty()), - "LongestCommonSubsequence requires at least one non-empty string.\n\n{usage}" - ); - anyhow::ensure!( - alphabet_size > 0, - "LongestCommonSubsequence requires a positive alphabet. Provide --alphabet-size when all strings are empty.\n\n{usage}" - ); - } - "LongestPath" => { - let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - if args.weights.is_some() { - bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); + 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 edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - let source_vertex = args.source_vertex.ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") - })?; - let target_vertex = args.target_vertex.ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let usage = + "Usage: pred create KClique --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; + let k = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; + ( + ser(KClique::new(graph, k))?, + variant_map(&[("graph", "SimpleGraph")]), + ) } - "MixedChinesePostman" => { - let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; - let graph = parse_mixed_graph(args, usage)?; - let arc_costs = parse_arc_costs(args, graph.num_arcs())?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - if arc_costs.iter().any(|&cost| cost < 0) { - bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); + + "VertexCover" => { + 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"); } - if edge_weights.iter().any(|&weight| weight < 0) { - bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let usage = + "Usage: pred create VertexCover --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; + let k = args + .k + .ok_or_else(|| anyhow::anyhow!("VertexCover requires --k\n\n{usage}"))?; + if k == 0 { + bail!("VertexCover: --k must be positive"); } - if resolved_variant.get("weight").map(String::as_str) == Some("One") - && (arc_costs.iter().any(|&cost| cost != 1) - || edge_weights.iter().any(|&weight| weight != 1)) - { - bail!( - "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ - Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." - ); + if k > graph.num_vertices() { + bail!("VertexCover: k must be <= graph num_vertices"); } + ( + ser(VertexCover::new(graph, k))?, + variant_map(&[("graph", "SimpleGraph")]), + ) } - "MinMaxMulticenter" => { - let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2"; - let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let vertex_weights = parse_vertex_weights(args, n)?; - let edge_lengths = parse_edge_weights(args, graph.num_edges())?; - let _ = args.k.ok_or_else(|| { - anyhow::anyhow!( - "MinMaxMulticenter requires --k (number of centers)\n\n\ - Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2" - ) - })?; - if vertex_weights.iter().any(|&weight| weight < 0) { - bail!("MinMaxMulticenter --weights must be non-negative"); - } - if edge_lengths.iter().any(|&length| length < 0) { - bail!("MinMaxMulticenter --edge-weights must be non-negative"); + + // MinimumCutIntoBoundedSets (graph + edge weights + s/t/B/K) + "MinimumCutIntoBoundedSets" => { + 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 num_edges = graph.num_edges(); + let edge_weights = vec![1i32; num_edges]; + let source = 0; + let sink = num_vertices.saturating_sub(1); + let size_bound = num_vertices; // no effective size constraint + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(MinimumCutIntoBoundedSets::new( + graph, + edge_weights, + source, + sink, + size_bound, + ))?, + variant, + ) } - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" - | "MaximalIS" => { - let graph_type = resolved_graph_type(resolved_variant); - let num_vertices = match graph_type { - "KingsSubgraph" | "TriangularSubgraph" => parse_int_positions(args)?.len(), - "UnitDiskGraph" => parse_float_positions(args)?.len(), - _ => { - parse_graph(args) - .map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", - canonical - ) - })? - .1 - } - }; - let weights = parse_vertex_weights(args, num_vertices)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; + + // MaximumAchromaticNumber (graph only, no weights) + "MaximumAchromaticNumber" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(problemreductions::models::graph::MaximumAchromaticNumber::new(graph))?, + variant, + ) } - "MinimumHittingSet" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "MinimumHittingSet requires --universe and --sets\n\n\ - Usage: pred create MinimumHittingSet --universe 6 --sets \"0,1,2;0,3,4;1,3,5;2,4,5;0,1,5;2,3;1,4\"" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } + + // MaximumDomaticNumber (graph only, no weights) + "MaximumDomaticNumber" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(problemreductions::models::graph::MaximumDomaticNumber::new(graph))?, + variant, + ) } - "MinimumDummyActivitiesPert" => { - let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("MinimumDummyActivitiesPert requires --arcs\n\n{usage}") - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; - let _ = MinimumDummyActivitiesPert::try_new(graph).map_err(anyhow::Error::msg)?; + + // MinimumCoveringByCliques (graph only, no weights) + "MinimumCoveringByCliques" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(problemreductions::models::graph::MinimumCoveringByCliques::new(graph))?, + variant, + ) } - "MinimumMultiwayCut" => { - let usage = - "Usage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_terminals(args, graph.num_vertices())?; - let _ = parse_edge_weights(args, graph.num_edges())?; + + // MinimumIntersectionGraphBasis (graph only, no weights) + "MinimumIntersectionGraphBasis" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(problemreductions::models::graph::MinimumIntersectionGraphBasis::new(graph))?, + variant, + ) } - "MultipleChoiceBranching" => { - let usage = "Usage: 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\" --threshold 10"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") - })?; - let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let _ = parse_arc_weights(args, num_arcs)?; - let _ = parse_partition_groups(args, num_arcs)?; - let _ = parse_multiple_choice_branching_threshold(args, usage)?; + + // MinimumMaximalMatching (graph only, no weights) + "MinimumMaximalMatching" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(MinimumMaximalMatching::new(graph))?, variant) } - "MultipleCopyFileAllocation" => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - let _ = parse_vertex_i64_values( - args.usage.as_deref(), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - let _ = parse_vertex_i64_values( - args.storage.as_deref(), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - } - "MultiprocessorScheduling" => { - let usage = "Usage: pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10"; - let lengths_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" - ) - })?; - let num_processors = args.num_processors.ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --num-processors\n\n{usage}") - })?; - anyhow::ensure!( - num_processors > 0, - "MultiprocessorScheduling requires --num-processors > 0\n\n{usage}" - ); - let _ = args.deadline.ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --deadline\n\n{usage}") - })?; - let _: Vec = util::parse_comma_list(lengths_str)?; - } - "PartialFeedbackEdgeSet" => { - 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"; - let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args - .budget - .as_deref() - .ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") - })? - .parse::() - .map_err(|e| { - anyhow::anyhow!( - "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" - ) - })?; - let _ = args.max_cycle_length.ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") - })?; - } - "PathConstrainedNetworkFlow" => { - let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; - let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(ref s) = args.capacities { - util::parse_comma_list(s)? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "capacities length ({}) must match number of arcs ({num_arcs})", - capacities.len() - ); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") - })?; - let _ = args.requirement.ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") - })?; - let paths = parse_prescribed_paths(args, num_arcs, usage)?; - validate_prescribed_paths_against_graph(&graph, &paths, source, sink, usage)?; - } - "ProductionPlanning" => { - let usage = "Usage: pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80"; - let num_periods = args.num_periods.ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") - })?; - let demands = parse_named_u64_list( - args.demands.as_deref(), - "ProductionPlanning", - "--demands", - usage, - )?; - let capacities = parse_named_u64_list( - args.capacities.as_deref(), - "ProductionPlanning", - "--capacities", - usage, - )?; - let setup_costs = parse_named_u64_list( - args.setup_costs.as_deref(), - "ProductionPlanning", - "--setup-costs", - usage, - )?; - let production_costs = parse_named_u64_list( - args.production_costs.as_deref(), - "ProductionPlanning", - "--production-costs", - usage, - )?; - let inventory_costs = parse_named_u64_list( - args.inventory_costs.as_deref(), - "ProductionPlanning", - "--inventory-costs", - usage, - )?; - let _ = args.cost_bound.ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --cost-bound\n\n{usage}") - })?; - for (flag, len) in [ - ("--demands", demands.len()), - ("--capacities", capacities.len()), - ("--setup-costs", setup_costs.len()), - ("--production-costs", production_costs.len()), - ("--inventory-costs", inventory_costs.len()), - ] { - ensure_named_len(len, num_periods, flag, usage)?; + // Hamiltonian Circuit (graph only, no weights) + "HamiltonianCircuit" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(HamiltonianCircuit::new(graph))?, variant) } - "SchedulingWithIndividualDeadlines" => { - let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and a processor count (--num-processors or --m)\n\n{usage}" - ) - })?; - let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" - ) - })?; - let num_processors = resolve_processor_count_flags( - "SchedulingWithIndividualDeadlines", - usage, - args.num_processors, - args.m, - )? - .ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-processors or --m\n\n{usage}" - ) - })?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?; - anyhow::ensure!( - deadlines.len() == num_tasks, - "deadlines length ({}) must equal num_tasks ({})", - deadlines.len(), - num_tasks - ); - for &(pred, succ) in &precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); + + // Maximum Leaf Spanning Tree (graph only, no weights) + "MaximumLeafSpanningTree" => { + 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 _ = SchedulingWithIndividualDeadlines::new( - num_tasks, - num_processors, - deadlines, - precedences, - ); - } - "StringToStringCorrection" => { - let usage = "Usage: pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2"; - let source_str = args.source_string.as_deref().ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") - })?; - let target_str = args.target_string.as_deref().ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") - })?; - let _ = parse_nonnegative_usize_bound( - args.bound.ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --bound\n\n{usage}") - })?, - "StringToStringCorrection", - usage, - )?; - let source = parse_symbol_list_allow_empty(source_str)?; - let target = parse_symbol_list_allow_empty(target_str)?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |m| m + 1); - let alphabet_size = args.alphabet_size.unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the strings", - alphabet_size, - inferred - ); + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(problemreductions::models::graph::MaximumLeafSpanningTree::new(graph))?, + variant, + ) } - "SparseMatrixCompression" => { - let matrix = parse_bool_matrix(args)?; - let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" - ) - })?; - let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; - if bound == 0 { - anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); + + // HamiltonianPath (graph only, no weights) + "HamiltonianPath" => { + 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 _ = SparseMatrixCompression::new(matrix, bound); + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(HamiltonianPath::new(graph))?, variant) } - "StackerCrane" => { - let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; - let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; - let (edges_graph, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - anyhow::ensure!( - edges_graph.num_vertices() == num_vertices, - "internal error: inconsistent graph vertex count" - ); + + // HamiltonianPathBetweenTwoVertices (graph + source/target) + "HamiltonianPathBetweenTwoVertices" => { + 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_vertex = args.source_vertex.unwrap_or(0); + let target_vertex = args + .target_vertex + .unwrap_or_else(|| num_vertices.saturating_sub(1)); + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; anyhow::ensure!( - num_vertices == arcs_graph.num_vertices(), - "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" + source_vertex != target_vertex, + "source_vertex and target_vertex must be distinct" ); - let arc_lengths = parse_arc_costs(args, num_arcs)?; - let edge_lengths = parse_i32_edge_values( - args.edge_lengths.as_ref(), - edges_graph.num_edges(), - "edge length", - )?; - let _ = problemreductions::models::misc::StackerCrane::try_new( - num_vertices, - arcs_graph.arcs(), - edges_graph.edges(), - arc_lengths, - edge_lengths, + let variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(HamiltonianPathBetweenTwoVertices::new( + graph, + source_vertex, + target_vertex, + ))?, + variant, ) - .map_err(|e| anyhow::anyhow!(e))?; } - "ThreePartition" => { - let sizes_str = args.sizes.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --sizes and --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = u64::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "ThreePartition requires a positive integer --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let _ = ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?; + + // LongestCircuit (graph + unit edge lengths) + "LongestCircuit" => { + 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 edge_lengths = vec![1i32; graph.num_edges()]; + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + (ser(LongestCircuit::new(graph, edge_lengths))?, variant) } - "UndirectedFlowLowerBounds" => { - let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; - let num_vertices = graph.num_vertices(); - let source = args.source.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") - })?; - let sink = args.sink.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") - })?; - let requirement = args.requirement.ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") - })?; + + // 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)?; - let _ = UndirectedFlowLowerBounds::new( - graph, - capacities, - lower_bounds, + 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 { + eprintln!( + "Warning: LengthBoundedDisjointPaths requires at least 2 vertices; rounding {} up to 2", + num_vertices + ); + 2 + } else { + num_vertices + }; + 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 bound = args.bound.unwrap_or((num_vertices - 1) as i64); + let max_length = validate_length_bounded_disjoint_paths_args( + num_vertices, source, sink, - requirement, - ); - } - "SequencingToMinimizeMaximumCumulativeCost" => { - let costs_str = args.costs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ - Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" - ) - })?; - let costs: Vec = util::parse_comma_list(costs_str)?; - let precedences = parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?; - validate_precedence_pairs(&precedences, costs.len())?; - } - "SequencingToMinimizeWeightedTardiness" => { - let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let weights_str = args.weights.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - anyhow::ensure!(bound >= 0, "--bound must be non-negative"); - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - anyhow::ensure!( - lengths.len() == weights.len(), - "lengths length ({}) must equal weights length ({})", - lengths.len(), - weights.len() - ); - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - } - "SequencingWithinIntervals" => { - let usage = - "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; - let rt_str = args.release_times.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") - })?; - let dl_str = args.deadlines.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") - })?; - let len_str = args.lengths.as_deref().ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --lengths\n\n{usage}") - })?; - let release_times: Vec = util::parse_comma_list(rt_str)?; - let deadlines: Vec = util::parse_comma_list(dl_str)?; - let lengths: Vec = util::parse_comma_list(len_str)?; - validate_sequencing_within_intervals_inputs( - &release_times, - &deadlines, - &lengths, - usage, + bound, + None, )?; + let variant = variant_map(&[("graph", "SimpleGraph")]); + ( + ser(LengthBoundedDisjointPaths::new( + graph, + source, + sink, + max_length, + ))?, + variant, + ) } - "SetBasis" => { - let universe = args.universe.ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --universe, --sets, and --k\n\n\ - Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let _ = args.k.ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --k\n\n\ - Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } + + // Graph problems with edge weights + "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + 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 num_edges = graph.num_edges(); + let edge_weights = vec![1i32; num_edges]; + let variant = match canonical { + "BottleneckTravelingSalesman" => variant_map(&[]), + _ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]), + }; + let data = match canonical { + "BottleneckTravelingSalesman" => { + ser(BottleneckTravelingSalesman::new(graph, edge_weights))? + } + "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, + "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, + "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, + _ => unreachable!(), + }; + (data, variant) } - "ShortestWeightConstrainedPath" => { - let usage = "Usage: pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if args.weights.is_some() { - bail!( - "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" - ); + + // SteinerTreeInGraphs + "SteinerTreeInGraphs" => { + 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 edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" - ) - })?; - let edge_weights_raw = args.edge_weights.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" - ) - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - let edge_weights = - parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - ensure_positive_i32_values(&edge_weights, "edge weights")?; - let source_vertex = args.source_vertex.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" - ) - })?; - let target_vertex = args.target_vertex.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" - ) - })?; - let weight_bound = args.weight_bound.ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" - ) - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - ensure_positive_i32(weight_bound, "weight_bound")?; + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let num_edges = graph.num_edges(); + let edge_weights = vec![1i32; num_edges]; + // Use first half of vertices as terminals (at least 2) + let num_terminals = std::cmp::max(2, num_vertices / 2); + let terminals: Vec = (0..num_terminals).collect(); + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, + variant, + ) } + + // SteinerTree "SteinerTree" => { - let usage = "Usage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_edge_weights(args, graph.num_edges())?; - let _ = parse_terminals(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "TimetableDesign" => { - let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; - let num_periods = args.num_periods.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") - })?; - let num_craftsmen = args.num_craftsmen.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") - })?; - let num_tasks = args.num_tasks.ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") - })?; - let craftsman_avail = - parse_named_bool_rows(args.craftsman_avail.as_deref(), "--craftsman-avail", usage)?; - let task_avail = - parse_named_bool_rows(args.task_avail.as_deref(), "--task-avail", usage)?; - let requirements = parse_timetable_requirements(args.requirements.as_deref(), usage)?; - validate_timetable_design_args( - num_periods, - num_craftsmen, - num_tasks, - &craftsman_avail, - &task_avail, - &requirements, - usage, - )?; - } - "UndirectedTwoCommodityIntegralFlow" => { - let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - for (edge_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at edge index {} is too large for this platform\n\n{}", - capacity, - edge_index, - usage - ); - } + anyhow::ensure!( + num_vertices >= 2, + "SteinerTree random generation requires --num-vertices >= 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 num_vertices = graph.num_vertices(); - let source_1 = args.source_1.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") - })?; - let sink_1 = args.sink_1.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") - })?; - let source_2 = args.source_2.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") - })?; - let sink_2 = args.sink_2.ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") - })?; - let _ = args.requirement_1.ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" - ) - })?; - let _ = args.requirement_2.ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" - ) - })?; - for (label, vertex) in [ - ("source-1", source_1), - ("sink-1", sink_1), - ("source-2", source_2), - ("sink-2", sink_2), - ] { - validate_vertex_index(label, vertex, num_vertices, usage)?; + let mut state = util::lcg_init(args.seed); + let graph = util::create_random_graph(num_vertices, edge_prob, Some(state)); + // Advance state past the graph generation + for _ in 0..num_vertices * num_vertices { + util::lcg_step(&mut state); } + let edge_weights: Vec = (0..graph.num_edges()) + .map(|_| (util::lcg_step(&mut state) * 9.0) as i32 + 1) + .collect(); + let num_terminals = std::cmp::max(2, num_vertices * 2 / 5); + let terminals = util::lcg_choose(&mut state, num_vertices, num_terminals); + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(SteinerTree::new(graph, edge_weights, terminals))?, + variant, + ) } - _ => {} - } - - Ok(()) -} -fn resolve_processor_count_flags( - problem_name: &str, - usage: &str, - num_processors: Option, - m_alias: Option, -) -> Result> { - match (num_processors, m_alias) { - (Some(num_processors), Some(m_alias)) => { - anyhow::ensure!( - num_processors == m_alias, - "{problem_name} received conflicting processor counts: --num-processors={num_processors} but --m={m_alias}\n\n{usage}" - ); - Ok(Some(num_processors)) + // SpinGlass + "SpinGlass" => { + 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 num_edges = graph.num_edges(); + let couplings = vec![1i32; num_edges]; + let fields = vec![0i32; num_vertices]; + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(SpinGlass::from_graph(graph, couplings, fields))?, + variant, + ) } - (Some(num_processors), None) => Ok(Some(num_processors)), - (None, Some(m_alias)) => Ok(Some(m_alias)), - (None, None) => Ok(None), - } -} - -fn validate_sequencing_within_intervals_inputs( - release_times: &[u64], - deadlines: &[u64], - lengths: &[u64], - usage: &str, -) -> Result<()> { - if release_times.len() != deadlines.len() { - bail!("release_times and deadlines must have the same length\n\n{usage}"); - } - if release_times.len() != lengths.len() { - bail!("release_times and lengths must have the same length\n\n{usage}"); - } - for (i, ((&release_time, &deadline), &length)) in release_times - .iter() - .zip(deadlines.iter()) - .zip(lengths.iter()) - .enumerate() - { - let end = release_time.checked_add(length).ok_or_else(|| { - anyhow::anyhow!("Task {i}: overflow computing r(i) + l(i)\n\n{usage}") - })?; - if end > deadline { - bail!( - "Task {i}: r({}) + l({}) > d({}), time window is empty\n\n{usage}", - release_time, - length, - deadline - ); + // KColoring + "KColoring" => { + 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 (k, _variant) = + util::validate_k_param(resolved_variant, args.k, Some(3), "KColoring")?; + util::ser_kcoloring(graph, k)? } - } - - Ok(()) -} -fn print_problem_help(canonical: &str, resolved_variant: &BTreeMap) -> Result<()> { - let graph_type = resolved_variant - .get("graph") - .map(String::as_str) - .filter(|graph_type| *graph_type != "SimpleGraph"); - let is_geometry = matches!( - graph_type, - Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") - ); - let schemas = collect_schemas(); - let schema = schemas.iter().find(|s| s.name == canonical); - - if let Some(s) = schema { - eprintln!("{}\n {}\n", canonical, s.description); - eprintln!("Parameters:"); - for field in &s.fields { - let flag_name = - problem_help_flag_name(canonical, &field.name, &field.type_name, is_geometry); - // For geometry variants, show --positions instead of --graph - if field.type_name == "G" && is_geometry { - let hint = type_format_hint(&field.type_name, graph_type); - eprintln!(" --{:<16} {} ({hint})", flag_name, field.description); - if graph_type == Some("UnitDiskGraph") { - eprintln!(" --{:<16} Distance threshold [default: 1.0]", "radius"); - } - } else if field.type_name == "DirectedGraph" { - // DirectedGraph fields use --arcs, not --graph - let hint = type_format_hint(&field.type_name, graph_type); - eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); - } else if field.type_name == "MixedGraph" { - eprintln!( - " --{:<16} Undirected edges E of the mixed graph (edge list: 0-1,1-2,2-3)", - "graph" - ); - eprintln!( - " --{:<16} Directed arcs A of the mixed graph (directed arcs: 0>1,1>2,2>0)", - "arcs" - ); - } else if field.type_name == "BipartiteGraph" { - eprintln!( - " --{:<16} Vertices in the left partition (integer)", - "left" - ); - eprintln!( - " --{:<16} Vertices in the right partition (integer)", - "right" - ); - eprintln!( - " --{:<16} Bipartite edges as left-right pairs (edge list: 0-0,0-1,1-2)", - "biedges" - ); - } else { - let hint = help_flag_hint(canonical, &field.name, &field.type_name, graph_type); - eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); + // OptimalLinearArrangement — graph only (optimization) + "OptimalLinearArrangement" => { + 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 variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(OptimalLinearArrangement::new(graph))?, variant) } - if canonical == "GraphPartitioning" { - eprintln!( - " --{:<16} Number of partitions in the balanced partitioning model (must be 2) (integer)", - "num-partitions" - ); - } - } else { - bail!("{}", crate::problem_name::unknown_problem_error(canonical)); - } - let example = schema_help_example_for(canonical, resolved_variant).or_else(|| { - let fallback = example_for(canonical, graph_type); - (!fallback.is_empty()).then(|| fallback.to_string()) - }); - if let Some(example) = example { - eprintln!("\nExample:"); - eprintln!( - " pred create {} {}", - match graph_type { - Some(g) => format!("{canonical}/{g}"), - None => canonical.to_string(), - }, - example - ); - } - Ok(()) -} - -fn schema_help_example_for( - canonical: &str, - resolved_variant: &BTreeMap, -) -> Option { - let schema = collect_schemas() - .into_iter() - .find(|schema| schema.name == canonical)?; - let example = problemreductions::example_db::find_model_example(&ProblemRef { - name: canonical.to_string(), - variant: resolved_variant.clone(), - }) - .ok()?; - let instance = example.instance.as_object()?; - let graph_type = resolved_variant - .get("graph") - .map(String::as_str) - .filter(|graph_type| *graph_type != "SimpleGraph"); - let is_geometry = matches!( - graph_type, - Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") - ); + // RootedTreeArrangement — graph + bound + "RootedTreeArrangement" => { + 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 n = graph.num_vertices(); + let usage = "Usage: pred create RootedTreeArrangement --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] [--bound 10]"; + let bound = args + .bound + .map(|b| parse_nonnegative_usize_bound(b, "RootedTreeArrangement", usage)) + .transpose()? + .unwrap_or((n.saturating_sub(1)) * graph.num_edges()); + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(RootedTreeArrangement::new(graph, bound))?, variant) + } - let mut args = Vec::new(); - for field in &schema.fields { - let value = instance.get(&field.name)?; - let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); - let flag_name = - schema_example_flag_name(canonical, &field.name, &field.type_name, is_geometry); - let rendered = - format_schema_help_example_value(canonical, &field.name, &concrete_type, value)?; - args.push(format!("--{flag_name} {}", quote_cli_arg(&rendered))); - } - Some(args.join(" ")) -} + _ => bail!( + "Random generation is not supported for {canonical}. \ + Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ + MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, VertexCover, TravelingSalesman, \ + BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, MaximumLeafSpanningTree, SteinerTree, \ + OptimalLinearArrangement, RootedTreeArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)" + ), + }; -fn schema_example_flag_name( - canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> String { - problem_help_flag_name(canonical, field_name, field_type, is_geometry) - .split('/') - .next() - .unwrap_or(field_name) - .trim_start_matches("--") - .to_string() -} + let output = ProblemJsonOutput { + problem_type: canonical.to_string(), + variant, + data, + }; -fn quote_cli_arg(raw: &str) -> String { - if raw.is_empty() - || raw.chars().any(|ch| { - ch.is_whitespace() - || matches!( - ch, - ';' | '>' | '|' | '[' | ']' | '{' | '}' | '(' | ')' | '"' | '\'' - ) - }) - { - format!("\"{}\"", raw.replace('\\', "\\\\").replace('"', "\\\"")) - } else { - raw.to_string() - } + emit_problem_output(&output, out) } -fn format_schema_help_example_value( - canonical: &str, - field_name: &str, - concrete_type: &str, - value: &serde_json::Value, -) -> Option { - match (canonical, field_name) { - ("ConsecutiveBlockMinimization", "matrix") - | ("FeasibleBasisExtension", "matrix") - | ("MinimumWeightDecoding", "matrix") - | ("MinimumWeightSolutionToLinearEquations", "matrix") => { - return serde_json::to_string(value).ok(); - } - _ => {} - } - match normalize_type_name(concrete_type).as_str() { - "SimpleGraph" => format_simple_graph_example(value), - "DirectedGraph" => format_directed_graph_example(value), - "Vec" => format_cnf_clause_list_example(value), - "Vec" => format_quantifier_list_example(value), - "Vec>" => format_job_shop_example(value), - "Vec<(Vec,Vec)>" => format_dependency_example(value), - "Vec" | "Vec" | "Vec" | "Vec" | "Vec" | "Vec" => { - format_scalar_array_example(value) +/// Parse implication rules from semicolon-separated "antecedents>consequent" strings. +/// +/// Format: "0,1>2;3>4;5,6,7>0" where antecedents are comma-separated indices +/// before the `>` and the consequent is the single index after. +fn parse_implications(s: &str) -> Result, usize)>> { + let mut implications = Vec::new(); + for part in s.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; } - "Vec" => format_bool_array_example(value), - "Vec>" | "Vec>" | "Vec>" | "Vec>" - | "Vec>" => format_nested_numeric_rows(value), - "Vec>" => format_bool_matrix_example(value), - "Vec" => Some( - value - .as_array()? - .iter() - .map(|entry| entry.as_str().map(str::to_string)) - .collect::>>()? - .join(";"), - ), - "usize" | "u64" | "i32" | "i64" | "f64" | "BigUint" => format_scalar_example(value), - _ => None, - } -} - -fn format_scalar_example(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::Number(number) => Some(number.to_string()), - serde_json::Value::String(string) => Some(string.clone()), - serde_json::Value::Bool(boolean) => Some(boolean.to_string()), - _ => None, + let (lhs, rhs) = part.split_once('>').ok_or_else(|| { + anyhow::anyhow!("Each implication must contain '>' separator: {part}") + })?; + let antecedents: Vec = lhs + .split(',') + .map(|x| x.trim().parse::()) + .collect::>() + .context(format!("Invalid antecedent index in implication: {part}"))?; + let consequent: usize = rhs + .trim() + .parse() + .context(format!("Invalid consequent index in implication: {part}"))?; + implications.push((antecedents, consequent)); } + Ok(implications) } -fn format_scalar_array_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(format_scalar_example) - .collect::>>()? - .join(","), - ) -} - -fn format_bool_array_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|entry| { - entry - .as_bool() - .map(|boolean| if boolean { "1" } else { "0" }.to_string()) - }) - .collect::>>()? - .join(","), - ) -} - -fn format_nested_numeric_rows(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|row| format_scalar_array_example(row)) - .collect::>>()? - .join(";"), - ) -} - -fn format_cnf_clause_list_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|clause| format_scalar_array_example(clause.get("literals")?)) - .collect::>>()? - .join(";"), - ) -} - -fn format_bool_matrix_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(format_bool_array_example) - .collect::>>()? - .join(";"), - ) -} - -fn format_simple_graph_example(value: &serde_json::Value) -> Option { - Some( - value - .get("edges")? - .as_array()? - .iter() - .map(|edge| { - let pair = edge.as_array()?; - Some(format!( - "{}-{}", - pair.first()?.as_u64()?, - pair.get(1)?.as_u64()? - )) - }) - .collect::>>()? - .join(","), - ) -} - -fn format_directed_graph_example(value: &serde_json::Value) -> Option { - Some( - value - .get("arcs")? - .as_array()? - .iter() - .map(|arc| { - let pair = arc.as_array()?; - Some(format!( - "{}>{}", - pair.first()?.as_u64()?, - pair.get(1)?.as_u64()? - )) - }) - .collect::>>()? - .join(","), - ) -} - -fn format_quantifier_list_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|entry| match entry.as_str()? { - "Exists" => Some("E".to_string()), - "ForAll" => Some("A".to_string()), - _ => None, - }) - .collect::>>()? - .join(","), - ) -} - -fn format_job_shop_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|job| { - Some( - job.as_array()? - .iter() - .map(|task| { - let task = task.as_array()?; - Some(format!( - "{}:{}", - task.first()?.as_u64()?, - task.get(1)?.as_u64()? - )) - }) - .collect::>>()? - .join(","), - ) - }) - .collect::>>()? - .join(";"), - ) -} - -fn format_dependency_example(value: &serde_json::Value) -> Option { - Some( - value - .as_array()? - .iter() - .map(|dependency| { - let dependency = dependency.as_array()?; - let lhs = format_scalar_array_example(dependency.first()?)?; - let rhs = format_scalar_array_example(dependency.get(1)?)?; - Some(format!("{lhs}>{rhs}")) - }) - .collect::>>()? - .join(";"), - ) -} - -fn problem_help_flag_name( - canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> String { - if field_type == "G" && is_geometry { - return "positions".to_string(); - } - if field_type == "DirectedGraph" { - return "arcs".to_string(); - } - if field_type == "MixedGraph" { - return "graph".to_string(); - } - if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { - return "max-length".to_string(); - } - if canonical == "GeneralizedHex" && field_name == "target" { - return "sink".to_string(); - } - if canonical == "StringToStringCorrection" { - return match field_name { - "source" => "source-string".to_string(), - "target" => "target-string".to_string(), - "bound" => "bound".to_string(), - _ => help_flag_name(canonical, field_name), - }; - } - help_flag_name(canonical, field_name) -} - -fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { - match usage { - Some(usage) => anyhow::anyhow!("{message}\n\n{usage}"), - None => anyhow::anyhow!("{message}"), - } -} - -fn validate_length_bounded_disjoint_paths_args( - num_vertices: usize, - source: usize, - sink: usize, - bound: i64, - usage: Option<&str>, -) -> Result { - let max_length = usize::try_from(bound).map_err(|_| { - lbdp_validation_error( - "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths", - usage, - ) - })?; - if source >= num_vertices || sink >= num_vertices { - return Err(lbdp_validation_error( - "--source and --sink must be valid graph vertices", - usage, - )); - } - if source == sink { - return Err(lbdp_validation_error( - "--source and --sink must be distinct", - usage, - )); - } - if max_length == 0 { - return Err(lbdp_validation_error( - "--max-length must be positive", - usage, - )); - } - Ok(max_length) -} - -/// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). -fn resolved_graph_type(variant: &BTreeMap) -> &str { - variant - .get("graph") - .map(|s| s.as_str()) - .unwrap_or("SimpleGraph") -} - -pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { - if args.example.is_some() { - return create_from_example(args, out); - } - - let problem = args.problem.as_ref().ok_or_else(|| { - anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") - })?; - let rgraph = problemreductions::rules::ReductionGraph::new(); - let resolved = match resolve_problem_ref(problem, &rgraph) { - Ok(resolved) => resolved, - Err(graph_err) => match resolve_catalog_problem_ref(problem) { - Ok(catalog_resolved) => { - if rgraph.variants_for(catalog_resolved.name()).is_empty() { - ProblemRef { - name: catalog_resolved.name().to_string(), - variant: catalog_resolved.variant().clone(), - } - } else { - return Err(graph_err); - } - } - Err(catalog_err) => { - let spec = parse_problem_spec(problem)?; - if rgraph.variants_for(&spec.name).is_empty() { - return Err(catalog_err); - } - return Err(graph_err); - } - }, - }; - let canonical = resolved.name.as_str(); - let resolved_variant = resolved.variant.clone(); - let graph_type = resolved_graph_type(&resolved_variant); - - if args.random { - return create_random(args, canonical, &resolved_variant, out); - } - - // ILP and CircuitSAT have complex input structures not suited for CLI flags. - // Check before the empty-flags help so they get a clear message. - if canonical == "ILP" || canonical == "CircuitSAT" { - bail!( - "CLI creation is not yet supported for {canonical}.\n\n\ - {canonical} instances are typically created via reduction:\n\ - pred create MIS --graph 0-1,1-2 | pred reduce - --to {canonical}\n\n\ - Or use the Rust API for direct construction." - ); - } - - // Show schema-driven help when no data flags are provided - if all_data_flags_empty(args) { - print_problem_help(canonical, &resolved_variant)?; - std::process::exit(2); - } - - let (data, variant) = create_schema_driven(args, canonical, &resolved_variant)? - .ok_or_else(|| { - anyhow::anyhow!( - "Schema-driven creation unexpectedly returned no instance for {canonical}. This indicates a missing parser, flag mapping, derived field, or schema/factory mismatch in create.rs." - ) - })?; - - let output = ProblemJsonOutput { - problem_type: canonical.to_string(), - variant, - data, - }; - - emit_problem_output(&output, out) -} - -/// Reject non-unit weights when the resolved variant uses `weight=One`. -fn reject_nonunit_weights_for_one_variant( - canonical: &str, - graph_type: &str, - variant: &BTreeMap, - weights: &[i32], -) -> Result<()> { - if variant.get("weight").map(|w| w.as_str()) == Some("One") && weights.iter().any(|&w| w != 1) { - bail!( - "Non-unit weights are not supported for the default unit-weight variant.\n\n\ - Use the weighted variant instead:\n \ - pred create {canonical}/{graph_type}/i32 --graph ... --weights ..." - ); - } - Ok(()) -} - -/// Create a vertex-weight problem dispatching on geometry graph type. -fn create_vertex_weight_problem( - args: &CreateArgs, - canonical: &str, - graph_type: &str, - resolved_variant: &BTreeMap, -) -> Result<(serde_json::Value, BTreeMap)> { - match graph_type { - "KingsSubgraph" => { - let positions = parse_int_positions(args)?; - let n = positions.len(); - let graph = KingsSubgraph::new(positions); - let weights = parse_vertex_weights(args, n)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - Ok(( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - "TriangularSubgraph" => { - let positions = parse_int_positions(args)?; - let n = positions.len(); - let graph = TriangularSubgraph::new(positions); - let weights = parse_vertex_weights(args, n)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - Ok(( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - "UnitDiskGraph" => { - let positions = parse_float_positions(args)?; - let n = positions.len(); - let radius = args.radius.unwrap_or(1.0); - let graph = UnitDiskGraph::new(positions, radius); - let weights = parse_vertex_weights(args, n)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - Ok(( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - _ => { - // SimpleGraph path (existing) - let (graph, n) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", - canonical - ) - })?; - let weights = parse_vertex_weights(args, n)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - let data = ser_vertex_weight_problem_with(canonical, graph, weights)?; - Ok((data, resolved_variant.clone())) - } - } -} - -/// Serialize a vertex-weight problem with a generic graph type. -fn ser_vertex_weight_problem_with( - canonical: &str, - graph: G, - weights: Vec, -) -> Result { - match canonical { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights)), - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights)), - "MaximumClique" => ser(MaximumClique::new(graph, weights)), - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights)), - "MaximalIS" => ser(MaximalIS::new(graph, weights)), - _ => unreachable!(), - } -} - -fn ser(problem: T) -> Result { - util::ser(problem) -} - -fn parse_kclique_threshold( - k_flag: Option, - num_vertices: usize, - usage: &str, -) -> Result { - let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires --k\n\n{usage}"))?; - if k == 0 { - bail!("KClique: --k must be positive"); - } - if k > num_vertices { - bail!("KClique: k must be <= graph num_vertices"); - } - Ok(k) -} - -fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { - util::variant_map(pairs) -} - -fn parse_bipartite_problem_input( - args: &CreateArgs, - canonical: &str, - k_description: &str, - usage: &str, -) -> Result<(BipartiteGraph, usize)> { - let left = args.left.ok_or_else(|| { - anyhow::anyhow!( - "{canonical} requires --left, --right, --biedges, and --k\n\nUsage: {usage}" - ) - })?; - let right = args.right.ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --right (right partition size)\n\nUsage: {usage}") - })?; - let k = args.k.ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --k ({k_description})\n\nUsage: {usage}") - })?; - let edges_str = args.biedges.as_deref().ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --biedges (e.g., 0-0,0-1,1-1)\n\nUsage: {usage}") - })?; - let edges = util::parse_edge_pairs(edges_str)?; - validate_bipartite_edges(canonical, left, right, &edges)?; - Ok((BipartiteGraph::new(left, right, edges), k)) -} - -fn validate_bipartite_edges( - canonical: &str, - left: usize, - right: usize, - edges: &[(usize, usize)], -) -> Result<()> { - for &(u, v) in edges { - if u >= left { - bail!("{canonical} edge {u}-{v} is out of bounds for left partition size {left}"); - } - if v >= right { - bail!("{canonical} edge {u}-{v} is out of bounds for right partition size {right}"); - } - } - Ok(()) -} - -/// Parse `--graph` into a SimpleGraph, optionally preserving isolated vertices -/// via `--num-vertices`. -fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { - let edges_str = args - .graph - .as_deref() - .ok_or_else(|| anyhow::anyhow!("This problem requires --graph (e.g., 0-1,1-2,2-3)"))?; - - if edges_str.trim().is_empty() { - let num_vertices = args.num_vertices.ok_or_else(|| { - anyhow::anyhow!( - "Empty graph string. To create a graph with isolated vertices, pass --num-vertices N as well." - ) - })?; - return Ok((SimpleGraph::empty(num_vertices), num_vertices)); - } - - let edges: Vec<(usize, usize)> = edges_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('-').collect(); - if parts.len() != 2 { - bail!("Invalid edge '{}': expected format u-v", pair.trim()); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - if u == v { - bail!( - "Self-loop detected: edge {}-{}. Simple graphs do not allow self-loops", - u, - v - ); - } - Ok((u, v)) - }) - .collect::>>()?; - - let inferred_num_vertices = edges - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_vertices = match args.num_vertices { - Some(explicit) if explicit < inferred_num_vertices => { - bail!( - "--num-vertices {} is too small for the provided graph; need at least {}", - explicit, - inferred_num_vertices - ); - } - Some(explicit) => explicit, - None => inferred_num_vertices, - }; - - Ok((SimpleGraph::new(num_vertices, edges), num_vertices)) -} - -/// Parse `--positions` as integer grid positions. -fn parse_int_positions(args: &CreateArgs) -> Result> { - let pos_str = args.positions.as_deref().ok_or_else(|| { - anyhow::anyhow!("This variant requires --positions (e.g., \"0,0;1,0;1,1\")") - })?; - util::parse_positions(pos_str, "0,0") -} - -/// Parse `--positions` as float positions. -fn parse_float_positions(args: &CreateArgs) -> Result> { - let pos_str = args.positions.as_deref().ok_or_else(|| { - anyhow::anyhow!("This variant requires --positions (e.g., \"0.0,0.0;1.0,0.0;0.5,0.87\")") - })?; - util::parse_positions(pos_str, "0.0,0.0") -} - -/// Parse `--weights` as vertex weights (i32), defaulting to all 1s. -fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result> { - match &args.weights { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_vertices { - bail!( - "Expected {} weights but got {}", - num_vertices, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_vertices]), - } -} - -fn parse_i32_edge_values( - values: Option<&String>, - num_edges: usize, - value_label: &str, -) -> Result> { - match values { - Some(raw) => { - let parsed: Vec = raw - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if parsed.len() != num_edges { - bail!( - "Expected {} {} values but got {}", - num_edges, - value_label, - parsed.len() - ); - } - Ok(parsed) - } - None => Ok(vec![1i32; num_edges]), - } -} - -fn parse_vertex_i64_values( - raw: Option<&str>, - field_name: &str, - num_vertices: usize, - problem_name: &str, - usage: &str, -) -> Result> { - let raw = - raw.ok_or_else(|| anyhow::anyhow!("{problem_name} requires --{field_name}\n\n{usage}"))?; - let values: Vec = util::parse_comma_list(raw) - .map_err(|e| anyhow::anyhow!("invalid {field_name} list: {e}\n\n{usage}"))?; - if values.len() != num_vertices { - bail!( - "Expected {} {} values but got {}\n\n{}", - num_vertices, - field_name, - values.len(), - usage - ); - } - Ok(values) -} - -/// Parse `--terminals` as comma-separated vertex indices. -fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> { - let s = args - .terminals - .as_deref() - .ok_or_else(|| anyhow::anyhow!("--terminals required (e.g., \"0,2,4\")"))?; - let terminals: Vec = s - .split(',') - .map(|t| t.trim().parse::()) - .collect::, _>>() - .context("invalid terminal index")?; - for &t in &terminals { - anyhow::ensure!( - t < num_vertices, - "terminal {t} >= num_vertices ({num_vertices})" - ); - } - let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect(); - anyhow::ensure!( - distinct_terminals.len() == terminals.len(), - "terminals must be distinct" - ); - anyhow::ensure!(terminals.len() >= 2, "at least 2 terminals required"); - Ok(terminals) -} - -/// Parse `--terminal-pairs` as comma-separated `u-v` vertex pairs. -fn parse_terminal_pairs(args: &CreateArgs, num_vertices: usize) -> Result> { - let raw = args - .terminal_pairs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("--terminal-pairs required (e.g., \"0-3,2-5\")"))?; - let terminal_pairs = util::parse_edge_pairs(raw)?; - anyhow::ensure!( - !terminal_pairs.is_empty(), - "at least 1 terminal pair required" - ); - - let mut used = BTreeSet::new(); - for &(source, sink) in &terminal_pairs { - anyhow::ensure!( - source < num_vertices, - "terminal pair source {source} >= num_vertices ({num_vertices})" - ); - anyhow::ensure!( - sink < num_vertices, - "terminal pair sink {sink} >= num_vertices ({num_vertices})" - ); - anyhow::ensure!(source != sink, "terminal pair endpoints must be distinct"); - anyhow::ensure!( - used.insert(source) && used.insert(sink), - "terminal vertices must be pairwise disjoint across terminal pairs" - ); - } - - Ok(terminal_pairs) -} - -fn ensure_positive_i32_values(values: &[i32], label: &str) -> Result<()> { - if values.iter().any(|&value| value <= 0) { - bail!("All {label} must be positive (> 0)"); - } - Ok(()) -} - -fn ensure_positive_i32(value: i32, label: &str) -> Result<()> { - if value <= 0 { - bail!("{label} must be positive (> 0)"); - } - Ok(()) -} - -fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> Result<()> { - if vertex >= num_vertices { - bail!("{label} {vertex} out of bounds (graph has {num_vertices} vertices)"); - } - Ok(()) -} - -/// Parse `--edge-weights` as per-edge numeric values (i32), defaulting to all 1s. -fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { - parse_i32_edge_values(args.edge_weights.as_ref(), num_edges, "edge weight") -} - -fn validate_vertex_index( - label: &str, - vertex: usize, - num_vertices: usize, - usage: &str, -) -> Result<()> { - if vertex < num_vertices { - return Ok(()); - } - - bail!("{label} must be less than num_vertices ({num_vertices})\n\n{usage}"); -} - -/// Parse `--capacities` as edge capacities (u64). -fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { - let capacities = args - .capacities - .as_deref() - .ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?; - let capacities: Vec = capacities - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - if capacities.len() != num_edges { - bail!( - "Expected {} capacities but got {}\n\n{}", - num_edges, - capacities.len(), - usage - ); - } - Ok(capacities) -} - -/// Parse `--lower-bounds` as edge lower bounds (u64). -fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { - let lower_bounds = args.lower_bounds.as_deref().ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}") - })?; - let lower_bounds: Vec = lower_bounds - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - if lower_bounds.len() != num_edges { - bail!( - "Expected {} lower bounds but got {}\n\n{}", - num_edges, - lower_bounds.len(), - usage - ); - } - Ok(lower_bounds) -} - -fn parse_bundle_capacities(args: &CreateArgs, num_bundles: usize, usage: &str) -> Result> { - let capacities = args.bundle_capacities.as_deref().ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --bundle-capacities\n\n{usage}") - })?; - let capacities: Vec = capacities - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid bundle capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - anyhow::ensure!( - capacities.len() == num_bundles, - "Expected {} bundle capacities but got {}\n\n{}", - num_bundles, - capacities.len(), - usage - ); - for (bundle_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - anyhow::ensure!( - fits, - "bundle capacity {} at bundle index {} is too large for this platform\n\n{}", - capacity, - bundle_index, - usage - ); - anyhow::ensure!( - capacity > 0, - "bundle capacity at bundle index {} must be positive\n\n{}", - bundle_index, - usage - ); - } - Ok(capacities) -} - -/// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. -fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { - match &args.couplings { - Some(w) => { - let vals: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if vals.len() != num_edges { - bail!("Expected {} couplings but got {}", num_edges, vals.len()); - } - Ok(vals) - } - None => Ok(vec![1i32; num_edges]), - } -} - -/// Parse `--fields` as SpinGlass on-site fields (i32), defaulting to all 0s. -fn parse_fields(args: &CreateArgs, num_vertices: usize) -> Result> { - match &args.fields { - Some(w) => { - let vals: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if vals.len() != num_vertices { - bail!("Expected {} fields but got {}", num_vertices, vals.len()); - } - Ok(vals) - } - None => Ok(vec![0i32; num_vertices]), - } -} - -/// Check if a CLI string value contains float syntax (a decimal point). -fn has_float_syntax(value: &Option) -> bool { - value.as_ref().is_some_and(|s| s.contains('.')) -} - -/// Parse `--couplings` as SpinGlass pairwise couplings (f64), defaulting to all 1.0. -fn parse_couplings_f64(args: &CreateArgs, num_edges: usize) -> Result> { - match &args.couplings { - Some(w) => { - let vals: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if vals.len() != num_edges { - bail!("Expected {} couplings but got {}", num_edges, vals.len()); - } - Ok(vals) - } - None => Ok(vec![1.0f64; num_edges]), - } -} - -/// Parse `--fields` as SpinGlass on-site fields (f64), defaulting to all 0.0. -fn parse_fields_f64(args: &CreateArgs, num_vertices: usize) -> Result> { - match &args.fields { - Some(w) => { - let vals: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if vals.len() != num_vertices { - bail!("Expected {} fields but got {}", num_vertices, vals.len()); - } - Ok(vals) - } - None => Ok(vec![0.0f64; num_vertices]), - } -} - -/// Parse `--clauses` as semicolon-separated clauses of comma-separated literals. -/// E.g., "1,2;-1,3;2,-3" -fn parse_clauses(args: &CreateArgs) -> Result> { - let clauses_str = args - .clauses - .as_deref() - .ok_or_else(|| anyhow::anyhow!("SAT problems require --clauses (e.g., \"1,2;-1,3\")"))?; - - clauses_str - .split(';') - .map(|clause| { - let literals: Vec = clause - .trim() - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - Ok(CNFClause::new(literals)) - }) - .collect() -} - -fn parse_disjuncts(args: &CreateArgs) -> Result>> { - let disjuncts_str = args - .disjuncts - .as_deref() - .or(args.clauses.as_deref()) - .ok_or_else(|| { - anyhow::anyhow!("NonTautology requires --disjuncts (e.g., \"1,2,3;-1,-2,-3\")") - })?; - - disjuncts_str - .split(';') - .map(|disjunct| { - disjunct - .trim() - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>() - .map_err(anyhow::Error::from) - }) - .collect() -} - -/// Parse `--subsets` as semicolon-separated sets of comma-separated usize. -/// E.g., "0,1;1,2;0,2" -fn parse_sets(args: &CreateArgs) -> Result>> { - parse_named_sets(args.sets.as_deref(), "--subsets") -} - -fn parse_named_sets(sets_str: Option<&str>, flag: &str) -> Result>> { - let sets_str = sets_str - .ok_or_else(|| anyhow::anyhow!("This problem requires {flag} (e.g., \"0,1;1,2;0,2\")"))?; - sets_str - .split(';') - .map(|set| { - set.trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid set element: {}", e)) - }) - .collect() - }) - .collect() -} - -fn parse_homologous_pairs(args: &CreateArgs) -> Result> { - let pairs = args.homologous_pairs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "IntegralFlowHomologousArcs requires --homologous-pairs (e.g., \"2=5;4=3\")" - ) - })?; - - pairs - .split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let entry = entry.trim(); - let (left, right) = entry.split_once('=').ok_or_else(|| { - anyhow::anyhow!( - "Invalid homologous pair '{}': expected format u=v (e.g., 2=5)", - entry - ) - })?; - let left = left.trim().parse::().with_context(|| { - format!("Invalid homologous pair '{}': expected format u=v", entry) - })?; - let right = right.trim().parse::().with_context(|| { - format!("Invalid homologous pair '{}': expected format u=v", entry) - })?; - Ok((left, right)) - }) - .collect() -} - -/// Parse a dependency string as semicolon-separated `lhs>rhs` pairs. -/// E.g., "0,1>2,3;2,3>0,1" -fn parse_deps(s: &str) -> Result, Vec)>> { - s.split(';') - .map(|dep| { - let parts: Vec<&str> = dep.split('>').collect(); - if parts.len() != 2 { - bail!("Invalid dependency format '{}': expected 'lhs>rhs'", dep); - } - let lhs = parse_index_list(parts[0])?; - let rhs = parse_index_list(parts[1])?; - Ok((lhs, rhs)) - }) - .collect() -} - -/// Parse a comma-separated list of usize indices. -fn parse_index_list(s: &str) -> Result> { - s.split(',') - .map(|x| { - x.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid index '{}': {}", x.trim(), e)) - }) - .collect() -} - -/// Parse `--dependencies` as semicolon-separated "lhs>rhs" pairs. -/// E.g., "0,1>2;0,2>3;1,3>4;2,4>5" means {0,1}->{2}, {0,2}->{3}, etc. -fn parse_dependencies(input: &str) -> Result, Vec)>> { - fn parse_dependency_side(side: &str) -> Result> { - if side.trim().is_empty() { - return Ok(vec![]); - } - side.split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid attribute index: {}", e)) - }) - .collect() - } - - input - .split(';') - .map(|dep| { - let parts: Vec<&str> = dep.trim().split('>').collect(); - if parts.len() != 2 { - bail!( - "Invalid dependency format: expected 'lhs>rhs', got '{}'", - dep.trim() - ); - } - let lhs = parse_dependency_side(parts[0])?; - let rhs = parse_dependency_side(parts[1])?; - Ok((lhs, rhs)) - }) - .collect() -} - -fn validate_comparative_containment_sets( - family_name: &str, - flag: &str, - universe_size: usize, - sets: &[Vec], -) -> Result<()> { - for (set_index, set) in sets.iter().enumerate() { - for &element in set { - anyhow::ensure!( - element < universe_size, - "{family_name} set {set_index} from {flag} contains element {element} outside universe of size {universe_size}" - ); - } - } - Ok(()) -} - -/// Parse `--partition` as semicolon-separated groups of comma-separated arc indices. -/// E.g., "0,1;2,3;4,7;5,6" -fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result>> { - let partition_str = args.partition.as_deref().ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")") - })?; - - let partition: Vec> = partition_str - .split(';') - .map(|group| { - group - .trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid partition index: {}", e)) - }) - .collect() - }) - .collect::>()?; - - let mut seen = vec![false; num_arcs]; - for group in &partition { - for &arc_index in group { - anyhow::ensure!( - arc_index < num_arcs, - "partition arc index {} out of range for {} arcs", - arc_index, - num_arcs - ); - anyhow::ensure!( - !seen[arc_index], - "partition arc index {} appears more than once", - arc_index - ); - seen[arc_index] = true; - } - } - anyhow::ensure!( - seen.iter().all(|present| *present), - "partition must cover every arc exactly once" - ); - - Ok(partition) -} - -fn parse_bundles(args: &CreateArgs, num_arcs: usize, usage: &str) -> Result>> { - let bundles_str = args - .bundles - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --bundles\n\n{usage}"))?; - - let bundles: Vec> = bundles_str - .split(';') - .map(|bundle| { - let bundle = bundle.trim(); - anyhow::ensure!( - !bundle.is_empty(), - "IntegralFlowBundles does not allow empty bundle entries\n\n{usage}" - ); - bundle - .split(',') - .map(|s| { - s.trim().parse::().with_context(|| { - format!("Invalid bundle arc index `{}`\n\n{usage}", s.trim()) - }) - }) - .collect::>>() - }) - .collect::>()?; - - let mut seen_overall = vec![false; num_arcs]; - for (bundle_index, bundle) in bundles.iter().enumerate() { - let mut seen_in_bundle = BTreeSet::new(); - for &arc_index in bundle { - anyhow::ensure!( - arc_index < num_arcs, - "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}\n\n{usage}" - ); - anyhow::ensure!( - seen_in_bundle.insert(arc_index), - "bundle {bundle_index} contains duplicate arc index {arc_index}\n\n{usage}" - ); - seen_overall[arc_index] = true; - } - } - anyhow::ensure!( - seen_overall.iter().all(|covered| *covered), - "bundles must cover every arc at least once\n\n{usage}" - ); - - Ok(bundles) -} - -fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) -> Result { - let raw_bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}") - })?; - anyhow::ensure!( - raw_bound >= 0, - "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" - ); - i32::try_from(raw_bound).map_err(|_| { - anyhow::anyhow!( - "MultipleChoiceBranching threshold must fit in a 32-bit signed integer, got {raw_bound}" - ) - }) -} - -/// Parse `--weights` for set-based problems (i32), defaulting to all 1s. -fn parse_set_weights(args: &CreateArgs, num_sets: usize) -> Result> { - parse_named_set_weights(args.weights.as_deref(), num_sets, "--weights") -} - -fn parse_named_set_weights( - weights_str: Option<&str>, - num_sets: usize, - flag: &str, -) -> Result> { - match weights_str { - Some(w) => { - let weights: Vec = util::parse_comma_list(w)?; - if weights.len() != num_sets { - bail!( - "Expected {} values for {} but got {}", - num_sets, - flag, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_sets]), - } -} - -fn parse_named_set_weights_f64( - weights_str: Option<&str>, - num_sets: usize, - flag: &str, -) -> Result> { - match weights_str { - Some(w) => { - let weights: Vec = util::parse_comma_list(w)?; - if weights.len() != num_sets { - bail!( - "Expected {} values for {} but got {}", - num_sets, - flag, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1.0f64; num_sets]), - } -} - -fn validate_comparative_containment_i32_weights( - family_name: &str, - flag: &str, - weights: &[i32], -) -> Result<()> { - for (index, weight) in weights.iter().enumerate() { - anyhow::ensure!( - *weight > 0, - "{family_name} weights from {flag} must be positive; found {weight} at index {index}" - ); - } - Ok(()) -} - -fn validate_comparative_containment_f64_weights( - family_name: &str, - flag: &str, - weights: &[f64], -) -> Result<()> { - for (index, weight) in weights.iter().enumerate() { - anyhow::ensure!( - weight.is_finite() && *weight > 0.0, - "{family_name} weights from {flag} must be finite and positive; found {weight} at index {index}" - ); - } - Ok(()) -} - -/// Parse `--matrix` as semicolon-separated rows of comma-separated bool values (0/1). -/// E.g., "1,0;0,1;1,1" -fn parse_bool_matrix(args: &CreateArgs) -> Result>> { - let matrix_str = args - .matrix - .as_deref() - .ok_or_else(|| anyhow::anyhow!("This problem requires --matrix (e.g., \"1,0;0,1;1,1\")"))?; - parse_bool_rows(matrix_str) -} - -fn parse_schedules(args: &CreateArgs, usage: &str) -> Result>> { - let schedules_str = args - .schedules - .as_deref() - .ok_or_else(|| anyhow::anyhow!("StaffScheduling requires --schedules\n\n{usage}"))?; - parse_bool_rows(schedules_str) -} - -fn parse_bool_rows(rows_str: &str) -> Result>> { - let matrix: Vec> = rows_str - .split(';') - .map(|row| { - row.trim() - .split(',') - .map(|entry| match entry.trim() { - "1" | "true" => Ok(true), - "0" | "false" => Ok(false), - other => Err(anyhow::anyhow!( - "Invalid boolean entry '{other}': expected 0/1 or true/false" - )), - }) - .collect() - }) - .collect::>()?; - - if let Some(expected_width) = matrix.first().map(Vec::len) { - anyhow::ensure!( - matrix.iter().all(|row| row.len() == expected_width), - "All rows in --matrix must have the same length" - ); - } - - Ok(matrix) -} - -fn parse_requirements(args: &CreateArgs, usage: &str) -> Result> { - let requirements_str = args - .requirements - .as_deref() - .ok_or_else(|| anyhow::anyhow!("StaffScheduling requires --requirements\n\n{usage}"))?; - util::parse_comma_list(requirements_str) -} - -fn parse_named_u64_list( - raw: Option<&str>, - problem: &str, - flag: &str, - usage: &str, -) -> Result> { - let raw = raw.ok_or_else(|| anyhow::anyhow!("{problem} requires {flag}\n\n{usage}"))?; - util::parse_comma_list(raw).map_err(|err| anyhow::anyhow!("{err}\n\n{usage}")) -} - -fn ensure_named_len(len: usize, expected: usize, flag: &str, usage: &str) -> Result<()> { - anyhow::ensure!( - len == expected, - "{flag} must contain exactly {expected} entries\n\n{usage}" - ); - Ok(()) -} - -fn validate_staff_scheduling_args( - schedules: &[Vec], - requirements: &[u64], - shifts_per_schedule: usize, - num_workers: u64, - usage: &str, -) -> Result<()> { - if num_workers >= usize::MAX as u64 { - bail!( - "StaffScheduling requires --num-workers to fit in usize for brute-force enumeration\n\n{usage}" - ); - } - - let num_periods = requirements.len(); - for (index, schedule) in schedules.iter().enumerate() { - if schedule.len() != num_periods { - bail!( - "schedule {} has {} periods, expected {}\n\n{}", - index, - schedule.len(), - num_periods, - usage - ); - } - let ones = schedule.iter().filter(|&&active| active).count(); - if ones != shifts_per_schedule { - bail!( - "schedule {} has {} active periods, expected {}\n\n{}", - index, - ones, - shifts_per_schedule, - usage - ); - } - } - - Ok(()) -} - -fn parse_named_bool_rows(rows: Option<&str>, flag: &str, usage: &str) -> Result>> { - let rows = rows.ok_or_else(|| anyhow::anyhow!("TimetableDesign requires {flag}\n\n{usage}"))?; - parse_bool_rows(rows).map_err(|err| { - let message = err.to_string().replace("--matrix", flag); - anyhow::anyhow!("{message}\n\n{usage}") - }) -} - -fn parse_timetable_requirements(requirements: Option<&str>, usage: &str) -> Result>> { - let requirements = requirements - .ok_or_else(|| anyhow::anyhow!("TimetableDesign requires --requirements\n\n{usage}"))?; - let matrix: Vec> = requirements - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>()?; - - if let Some(expected_width) = matrix.first().map(Vec::len) { - anyhow::ensure!( - matrix.iter().all(|row| row.len() == expected_width), - "All rows in --requirements must have the same length" - ); - } - - Ok(matrix) -} - -fn validate_timetable_design_args( - num_periods: usize, - num_craftsmen: usize, - num_tasks: usize, - craftsman_avail: &[Vec], - task_avail: &[Vec], - requirements: &[Vec], - usage: &str, -) -> Result<()> { - anyhow::ensure!( - craftsman_avail.len() == num_craftsmen, - "craftsman availability row count ({}) must equal num_craftsmen ({})\n\n{}", - craftsman_avail.len(), - num_craftsmen, - usage - ); - anyhow::ensure!( - task_avail.len() == num_tasks, - "task availability row count ({}) must equal num_tasks ({})\n\n{}", - task_avail.len(), - num_tasks, - usage - ); - anyhow::ensure!( - requirements.len() == num_craftsmen, - "requirements row count ({}) must equal num_craftsmen ({})\n\n{}", - requirements.len(), - num_craftsmen, - usage - ); - - for (index, row) in craftsman_avail.iter().enumerate() { - anyhow::ensure!( - row.len() == num_periods, - "craftsman availability row {} has {} periods, expected {}\n\n{}", - index, - row.len(), - num_periods, - usage - ); - } - for (index, row) in task_avail.iter().enumerate() { - anyhow::ensure!( - row.len() == num_periods, - "task availability row {} has {} periods, expected {}\n\n{}", - index, - row.len(), - num_periods, - usage - ); - } - for (index, row) in requirements.iter().enumerate() { - anyhow::ensure!( - row.len() == num_tasks, - "requirements row {} has {} tasks, expected {}\n\n{}", - index, - row.len(), - num_tasks, - usage - ); - } - - Ok(()) -} - -/// Parse `--matrix` as semicolon-separated rows of comma-separated f64 values. -/// E.g., "1,0.5;0.5,2" -fn parse_matrix(args: &CreateArgs) -> Result>> { - let matrix_str = args - .matrix - .as_deref() - .ok_or_else(|| anyhow::anyhow!("QUBO requires --matrix (e.g., \"1,0.5;0.5,2\")"))?; - - matrix_str - .split(';') - .map(|row| { - row.trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid matrix value: {}", e)) - }) - .collect() - }) - .collect() -} - -fn parse_u64_matrix_rows(matrix_str: &str, matrix_name: &str) -> Result>> { - matrix_str - .split(';') - .enumerate() - .map(|(row_index, row)| { - let row = row.trim(); - anyhow::ensure!( - !row.is_empty(), - "{matrix_name} row {row_index} must not be empty" - ); - row.split(',') - .map(|value| { - value.trim().parse::().map_err(|error| { - anyhow::anyhow!( - "Invalid {matrix_name} row {row_index} value {:?}: {}", - value.trim(), - error - ) - }) - }) - .collect() - }) - .collect() -} - -/// Parse `--quantifiers` as comma-separated quantifier labels (E/A or Exists/ForAll). -/// E.g., "E,A,E" or "Exists,ForAll,Exists" -fn parse_quantifiers(args: &CreateArgs, num_vars: usize) -> Result> { - let q_str = args - .quantifiers - .as_deref() - .ok_or_else(|| anyhow::anyhow!("QBF requires --quantifiers (e.g., \"E,A,E\")"))?; - - let quantifiers: Vec = q_str - .split(',') - .map(|s| match s.trim().to_lowercase().as_str() { - "e" | "exists" => Ok(Quantifier::Exists), - "a" | "forall" => Ok(Quantifier::ForAll), - other => Err(anyhow::anyhow!( - "Invalid quantifier '{}': expected E/Exists or A/ForAll", - other - )), - }) - .collect::>>()?; - - if quantifiers.len() != num_vars { - bail!( - "Expected {} quantifiers but got {}", - num_vars, - quantifiers.len() - ); - } - Ok(quantifiers) -} - -/// Parse a semicolon-separated matrix of i64 values. -/// E.g., "0,5;5,0" -fn parse_i64_matrix(s: &str) -> Result>> { - let matrix: Vec> = s - .split(';') - .enumerate() - .map(|(row_idx, row)| { - row.trim() - .split(',') - .enumerate() - .map(|(col_idx, v)| { - v.trim().parse::().map_err(|e| { - anyhow::anyhow!("Invalid value at row {row_idx}, col {col_idx}: {e}") - }) - }) - .collect() - }) - .collect::>()?; - if let Some(first_len) = matrix.first().map(|r| r.len()) { - for (i, row) in matrix.iter().enumerate() { - if row.len() != first_len { - bail!( - "Ragged matrix: row {i} has {} columns, expected {first_len}", - row.len() - ); - } - } - } - Ok(matrix) -} - -fn parse_potential_edges(args: &CreateArgs) -> Result> { - let edges_str = args.potential_edges.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "BiconnectivityAugmentation requires --potential-weights (e.g., 0-2:3,1-3:5)" - ) - })?; - - edges_str - .split(',') - .map(|entry| { - let entry = entry.trim(); - let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { - anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") - })?; - let (u_str, v_str) = edge_part.split_once('-').ok_or_else(|| { - anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") - })?; - let u = u_str.trim().parse::()?; - let v = v_str.trim().parse::()?; - if u == v { - bail!("Self-loop detected in potential edge {u}-{v}"); - } - let weight = weight_part.trim().parse::()?; - Ok((u, v, weight)) - }) - .collect() -} - -fn validate_potential_edges( - graph: &SimpleGraph, - potential_edges: &[(usize, usize, i32)], -) -> Result<()> { - let num_vertices = graph.num_vertices(); - let mut seen_potential_edges = BTreeSet::new(); - for &(u, v, _) in potential_edges { - if u >= num_vertices || v >= num_vertices { - bail!( - "Potential edge {u}-{v} references a vertex outside the graph (num_vertices = {num_vertices})" - ); - } - let edge = if u <= v { (u, v) } else { (v, u) }; - if graph.has_edge(edge.0, edge.1) { - bail!( - "Potential edge {}-{} already exists in the graph", - edge.0, - edge.1 - ); - } - if !seen_potential_edges.insert(edge) { - bail!( - "Duplicate potential edge {}-{} is not allowed", - edge.0, - edge.1 - ); - } - } - Ok(()) -} - -fn parse_budget(args: &CreateArgs) -> Result { - let budget = args - .budget - .as_deref() - .ok_or_else(|| anyhow::anyhow!("BiconnectivityAugmentation requires --budget (e.g., 5)"))?; - budget - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid budget '{budget}': {e}")) -} - -/// Parse `--arcs` as directed arc pairs and build a `DirectedGraph`. -/// -/// Returns `(graph, num_arcs)`. Infers vertex count from arc endpoints -/// unless `num_vertices` is provided (which must be >= inferred count). -/// E.g., "0>1,1>2,2>0" -fn parse_directed_graph( - arcs_str: &str, - num_vertices: Option, -) -> Result<(DirectedGraph, usize)> { - let arcs: Vec<(usize, usize)> = arcs_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('>').collect(); - if parts.len() != 2 { - bail!( - "Invalid arc '{}': expected format u>v (e.g., 0>1)", - pair.trim() - ); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - Ok((u, v)) - }) - .collect::>>()?; - let inferred_num_v = arcs - .iter() - .flat_map(|&(u, v)| [u, v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_v = match num_vertices { - Some(user_num_v) => { - anyhow::ensure!( - user_num_v >= inferred_num_v, - "--num-vertices ({}) is too small for the arcs: need at least {} to cover vertices up to {}", - user_num_v, - inferred_num_v, - inferred_num_v.saturating_sub(1), - ); - user_num_v - } - None => inferred_num_v, - }; - let num_arcs = arcs.len(); - Ok((DirectedGraph::new(num_v, arcs), num_arcs)) -} - -fn parse_prescribed_paths( - args: &CreateArgs, - num_arcs: usize, - usage: &str, -) -> Result>> { - let paths_str = args - .paths - .as_deref() - .ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?; - - paths_str - .split(';') - .map(|path_str| { - let trimmed = path_str.trim(); - anyhow::ensure!( - !trimmed.is_empty(), - "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" - ); - let path: Vec = util::parse_comma_list(trimmed)?; - anyhow::ensure!( - !path.is_empty(), - "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" - ); - for &arc_idx in &path { - anyhow::ensure!( - arc_idx < num_arcs, - "Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}" - ); - } - Ok(path) - }) - .collect() -} - -fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result { - let (undirected_graph, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?; - let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices)) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - Ok(MixedGraph::new( - num_vertices, - directed_graph.arcs(), - undirected_graph.edges(), - )) -} - -/// Parse `--weights` as arc weights (i32), defaulting to all 1s. -fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { - match &args.weights { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_arcs { - bail!( - "Expected {} arc weights but got {}", - num_arcs, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_arcs]), - } -} - -/// Parse `--arc-weights` / `--arc-lengths` as per-arc costs (i32), defaulting to all 1s. -fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { - match &args.arc_costs { - Some(costs) => { - let parsed: Vec = costs - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if parsed.len() != num_arcs { - bail!("Expected {} arc costs but got {}", num_arcs, parsed.len()); - } - Ok(parsed) - } - None => Ok(vec![1i32; num_arcs]), - } -} - -/// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. -fn parse_candidate_arcs( - args: &CreateArgs, - num_vertices: usize, -) -> Result> { - let arcs_str = args.candidate_arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "StrongConnectivityAugmentation requires --candidate-arcs (e.g., \"2>0:1,2>1:3\")" - ) - })?; - - arcs_str - .split(',') - .map(|entry| { - let entry = entry.trim(); - let (arc_part, weight_part) = entry.split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid candidate arc '{}': expected format u>v:w (e.g., 2>0:1)", - entry - ) - })?; - let parts: Vec<&str> = arc_part.split('>').collect(); - if parts.len() != 2 { - bail!( - "Invalid candidate arc '{}': expected format u>v:w (e.g., 2>0:1)", - entry - ); - } - - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - anyhow::ensure!( - u < num_vertices && v < num_vertices, - "candidate arc ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); - - let w: i32 = weight_part.parse()?; - Ok((u, v, w)) - }) - .collect() -} - -/// Handle `pred create --random ...` -fn create_random( - args: &CreateArgs, - canonical: &str, - resolved_variant: &BTreeMap, - out: &OutputConfig, -) -> Result<()> { - let num_vertices = args.num_vertices.ok_or_else(|| { - anyhow::anyhow!( - "--random requires --num-vertices\n\n\ - Usage: pred create {} --random --num-vertices 10 [--edge-prob 0.3] [--seed 42]", - canonical - ) - })?; - - let graph_type = resolved_graph_type(resolved_variant); - - let (data, variant) = match canonical { - // Graph problems with vertex weights - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" - | "MaximalIS" => { - let weights = vec![1i32; num_vertices]; - match graph_type { - "KingsSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.seed); - let graph = KingsSubgraph::new(positions); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "TriangularSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.seed); - let graph = TriangularSubgraph::new(positions); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "UnitDiskGraph" => { - let radius = args.radius.unwrap_or(1.0); - let positions = util::create_random_float_positions(num_vertices, args.seed); - let graph = UnitDiskGraph::new(positions, radius); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - _ => { - 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 variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = ser_vertex_weight_problem_with(canonical, graph, weights)?; - (data, variant) - } - } - } - - "KClique" => { - 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 usage = - "Usage: pred create KClique --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; - let k = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; - ( - ser(KClique::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - "VertexCover" => { - 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 usage = - "Usage: pred create VertexCover --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; - let k = args - .k - .ok_or_else(|| anyhow::anyhow!("VertexCover requires --k\n\n{usage}"))?; - if k == 0 { - bail!("VertexCover: --k must be positive"); - } - if k > graph.num_vertices() { - bail!("VertexCover: k must be <= graph num_vertices"); - } - ( - ser(VertexCover::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MinimumCutIntoBoundedSets (graph + edge weights + s/t/B/K) - "MinimumCutIntoBoundedSets" => { - 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 num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - let source = 0; - let sink = num_vertices.saturating_sub(1); - let size_bound = num_vertices; // no effective size constraint - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(MinimumCutIntoBoundedSets::new( - graph, - edge_weights, - source, - sink, - size_bound, - ))?, - variant, - ) - } - - // MaximumAchromaticNumber (graph only, no weights) - "MaximumAchromaticNumber" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumAchromaticNumber::new(graph))?, - variant, - ) - } - - // MaximumDomaticNumber (graph only, no weights) - "MaximumDomaticNumber" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumDomaticNumber::new(graph))?, - variant, - ) - } - - // MinimumCoveringByCliques (graph only, no weights) - "MinimumCoveringByCliques" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MinimumCoveringByCliques::new(graph))?, - variant, - ) - } - - // MinimumIntersectionGraphBasis (graph only, no weights) - "MinimumIntersectionGraphBasis" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MinimumIntersectionGraphBasis::new(graph))?, - variant, - ) - } - - // MinimumMaximalMatching (graph only, no weights) - "MinimumMaximalMatching" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(MinimumMaximalMatching::new(graph))?, variant) - } - - // Hamiltonian Circuit (graph only, no weights) - "HamiltonianCircuit" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(HamiltonianCircuit::new(graph))?, variant) - } - - // Maximum Leaf Spanning Tree (graph only, no weights) - "MaximumLeafSpanningTree" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumLeafSpanningTree::new(graph))?, - variant, - ) - } - - // HamiltonianPath (graph only, no weights) - "HamiltonianPath" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(HamiltonianPath::new(graph))?, variant) - } - - // HamiltonianPathBetweenTwoVertices (graph + source/target) - "HamiltonianPathBetweenTwoVertices" => { - 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_vertex = args.source_vertex.unwrap_or(0); - let target_vertex = args - .target_vertex - .unwrap_or_else(|| num_vertices.saturating_sub(1)); - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - anyhow::ensure!( - source_vertex != target_vertex, - "source_vertex and target_vertex must be distinct" - ); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(HamiltonianPathBetweenTwoVertices::new( - graph, - source_vertex, - target_vertex, - ))?, - variant, - ) - } - - // LongestCircuit (graph + unit edge lengths) - "LongestCircuit" => { - 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 edge_lengths = vec![1i32; graph.num_edges()]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - (ser(LongestCircuit::new(graph, edge_lengths))?, 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 { - eprintln!( - "Warning: LengthBoundedDisjointPaths requires at least 2 vertices; rounding {} up to 2", - num_vertices - ); - 2 - } else { - num_vertices - }; - 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 bound = args.bound.unwrap_or((num_vertices - 1) as i64); - let max_length = validate_length_bounded_disjoint_paths_args( - num_vertices, - source, - sink, - bound, - None, - )?; - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(LengthBoundedDisjointPaths::new( - graph, - source, - sink, - max_length, - ))?, - variant, - ) - } - - // Graph problems with edge weights - "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - 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 num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - let variant = match canonical { - "BottleneckTravelingSalesman" => variant_map(&[]), - _ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]), - }; - let data = match canonical { - "BottleneckTravelingSalesman" => { - ser(BottleneckTravelingSalesman::new(graph, edge_weights))? - } - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - (data, variant) - } - - // SteinerTreeInGraphs - "SteinerTreeInGraphs" => { - 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 num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - // Use first half of vertices as terminals (at least 2) - let num_terminals = std::cmp::max(2, num_vertices / 2); - let terminals: Vec = (0..num_terminals).collect(); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, - variant, - ) - } - - // SteinerTree - "SteinerTree" => { - anyhow::ensure!( - num_vertices >= 2, - "SteinerTree random generation requires --num-vertices >= 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 mut state = util::lcg_init(args.seed); - let graph = util::create_random_graph(num_vertices, edge_prob, Some(state)); - // Advance state past the graph generation - for _ in 0..num_vertices * num_vertices { - util::lcg_step(&mut state); - } - let edge_weights: Vec = (0..graph.num_edges()) - .map(|_| (util::lcg_step(&mut state) * 9.0) as i32 + 1) - .collect(); - let num_terminals = std::cmp::max(2, num_vertices * 2 / 5); - let terminals = util::lcg_choose(&mut state, num_vertices, num_terminals); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SteinerTree::new(graph, edge_weights, terminals))?, - variant, - ) - } - - // SpinGlass - "SpinGlass" => { - 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 num_edges = graph.num_edges(); - let couplings = vec![1i32; num_edges]; - let fields = vec![0i32; num_vertices]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - variant, - ) - } - - // KColoring - "KColoring" => { - 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 (k, _variant) = - util::validate_k_param(resolved_variant, args.k, Some(3), "KColoring")?; - util::ser_kcoloring(graph, k)? - } - - // OptimalLinearArrangement — graph only (optimization) - "OptimalLinearArrangement" => { - 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 variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(OptimalLinearArrangement::new(graph))?, variant) - } - - // RootedTreeArrangement — graph + bound - "RootedTreeArrangement" => { - 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 n = graph.num_vertices(); - let usage = "Usage: pred create RootedTreeArrangement --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] [--bound 10]"; - let bound = args - .bound - .map(|b| parse_nonnegative_usize_bound(b, "RootedTreeArrangement", usage)) - .transpose()? - .unwrap_or((n.saturating_sub(1)) * graph.num_edges()); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(RootedTreeArrangement::new(graph, bound))?, variant) - } - - _ => bail!( - "Random generation is not supported for {canonical}. \ - Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, VertexCover, TravelingSalesman, \ - BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, MaximumLeafSpanningTree, SteinerTree, \ - OptimalLinearArrangement, RootedTreeArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)" - ), - }; - - let output = ProblemJsonOutput { - problem_type: canonical.to_string(), - variant, - data, - }; - - emit_problem_output(&output, out) -} - -/// Parse implication rules from semicolon-separated "antecedents>consequent" strings. -/// -/// Format: "0,1>2;3>4;5,6,7>0" where antecedents are comma-separated indices -/// before the `>` and the consequent is the single index after. -fn parse_implications(s: &str) -> Result, usize)>> { - let mut implications = Vec::new(); - for part in s.split(';') { - let part = part.trim(); - if part.is_empty() { - continue; - } - let (lhs, rhs) = part.split_once('>').ok_or_else(|| { - anyhow::anyhow!("Each implication must contain '>' separator: {part}") - })?; - let antecedents: Vec = lhs - .split(',') - .map(|x| x.trim().parse::()) - .collect::>() - .context(format!("Invalid antecedent index in implication: {part}"))?; - let consequent: usize = rhs - .trim() - .parse() - .context(format!("Invalid consequent index in implication: {part}"))?; - implications.push((antecedents, consequent)); - } - Ok(implications) -} - -#[cfg(test)] -mod tests { - 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::*; - use super::{ensure_attribute_indices_in_range, problem_help_flag_name}; - use crate::cli::{Cli, Commands}; - 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() { - assert_eq!( - problem_help_flag_name("LengthBoundedDisjointPaths", "max_length", "usize", false), - "max-length" - ); - } - - #[test] - fn test_problem_help_preserves_generic_field_kebab_case() { - assert_eq!( - problem_help_flag_name("LengthBoundedDisjointPaths", "max_paths", "usize", false,), - "max-paths" - ); - } - - #[test] - fn test_help_flag_name_mentions_m_alias_for_scheduling_processors() { - assert_eq!( - help_flag_name("SchedulingWithIndividualDeadlines", "num_processors"), - "num-processors/--m" - ); - assert_eq!( - help_flag_name("FlowShopScheduling", "num_processors"), - "num-processors/--m" - ); - } - - #[test] - fn test_parse_field_value_parses_simple_graph_to_json() { - let value = parse_field_value("SimpleGraph", "graph", "0-1,1-2", &CreateContext::default()) - .expect("parse graph"); - - assert_eq!( - value, - serde_json::json!({ - "num_vertices": 3, - "edges": [[0, 1], [1, 2]], - }) - ); - } - - #[test] - fn test_parse_field_value_parses_dependency_pairs() { - let value = parse_field_value( - "Vec<(Vec, Vec)>", - "dependencies", - "0,1>2,3;2>4", - &CreateContext::default(), - ) - .expect("parse dependencies"); - - assert_eq!(value, serde_json::json!([[[0, 1], [2, 3]], [[2], [4]],])); - } - - #[test] - fn test_parse_field_value_parses_job_shop_jobs() { - let value = parse_field_value( - "Vec>", - "jobs", - "0:3,1:4;1:2,0:3,1:2", - &CreateContext::default(), - ) - .expect("parse jobs"); - - assert_eq!( - value, - serde_json::json!([[[0, 3], [1, 4]], [[1, 2], [0, 3], [1, 2]],]) - ); - } - - #[test] - fn test_parse_field_value_parses_quantifiers_using_context_num_vars() { - let context = CreateContext::default().with_field("num_vars", serde_json::json!(3)); - let value = parse_field_value("Vec", "quantifiers", "E,A,E", &context) - .expect("parse quantifiers"); - - assert_eq!(value, serde_json::json!(["Exists", "ForAll", "Exists"])); - } - - #[test] - fn test_schema_driven_supported_problem_includes_cli_creatable_problem() { - assert!( - schema_driven_supported_problem("ConjunctiveBooleanQuery"), - "all CLI-creatable problems should opt into schema-driven create unless explicitly excluded" - ); - assert!(!schema_driven_supported_problem("ILP")); - assert!(!schema_driven_supported_problem("CircuitSAT")); - } - - #[test] - fn test_create_schema_driven_builds_job_shop_scheduling() { - let cli = Cli::parse_from([ - "pred", - "create", - "JobShopScheduling", - "--jobs", - "0:3,1:4;1:2,0:3,1:2", - "--num-processors", - "2", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = create_schema_driven(&args, "JobShopScheduling", &BTreeMap::new()) - .expect("schema-driven create should parse") - .expect("schema-driven path should support JobShopScheduling"); - - let entry = problemreductions::registry::find_variant_entry("JobShopScheduling", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["num_processors"], 2); - assert_eq!(data["jobs"][0], serde_json::json!([[0, 3], [1, 4]])); - } - - #[test] - fn test_create_schema_driven_builds_quantified_boolean_formulas() { - let cli = Cli::parse_from([ - "pred", - "create", - "QuantifiedBooleanFormulas", - "--num-vars", - "3", - "--quantifiers", - "E,A,E", - "--clauses", - "1,2;-1,3", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = - create_schema_driven(&args, "QuantifiedBooleanFormulas", &BTreeMap::new()) - .expect("schema-driven create should parse") - .expect("schema-driven path should support QBF"); - - let entry = - problemreductions::registry::find_variant_entry("QuantifiedBooleanFormulas", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!( - data["quantifiers"], - serde_json::json!(["Exists", "ForAll", "Exists"]) - ); - } - - #[test] - fn test_create_schema_driven_builds_undirected_flow_lower_bounds() { - let cli = Cli::parse_from([ - "pred", - "create", - "UndirectedFlowLowerBounds", - "--graph", - "0-1,0-2,1-3,2-3", - "--capacities", - "2,2,2,2", - "--lower-bounds", - "1,0,0,1", - "--source", - "0", - "--sink", - "3", - "--requirement", - "2", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = - create_schema_driven(&args, "UndirectedFlowLowerBounds", &BTreeMap::new()) - .expect("schema-driven create should parse") - .expect("schema-driven path should support UndirectedFlowLowerBounds"); - - let entry = - problemreductions::registry::find_variant_entry("UndirectedFlowLowerBounds", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["graph"]["num_vertices"], 4); - assert_eq!(data["capacities"], serde_json::json!([2, 2, 2, 2])); - assert_eq!(data["lower_bounds"], serde_json::json!([1, 0, 0, 1])); - } - - #[test] - fn test_create_schema_driven_builds_conjunctive_boolean_query() { - let cli = Cli::parse_from([ - "pred", - "create", - "ConjunctiveBooleanQuery", - "--domain-size", - "6", - "--relations", - "2:0,3|1,3;3:0,1,5|1,2,5", - "--conjuncts-spec", - "0:v0,c3;0:v1,c3;1:v0,v1,c5", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = - create_schema_driven(&args, "ConjunctiveBooleanQuery", &BTreeMap::new()) - .expect("schema-driven create should parse") - .expect("schema-driven path should support CBQ"); - - let entry = - problemreductions::registry::find_variant_entry("ConjunctiveBooleanQuery", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["num_variables"], 2); - assert_eq!(data["relations"][0]["arity"], 2); - assert_eq!( - data["conjuncts"][1], - serde_json::json!([0, [{"Variable": 1}, {"Constant": 3}]]) - ); - } - - #[test] - fn test_create_schema_driven_builds_closest_vector_problem_with_default_bounds() { - let cli = Cli::parse_from([ - "pred", - "create", - "CVP", - "--basis", - "1,0;0,1", - "--target-vec", - "0.5,0.5", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let resolved_variant = variant_map(&[("weight", "i32")]); - let (data, variant) = - create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) - .expect("schema-driven create should parse") - .expect("schema-driven path should support CVP"); - - let entry = - problemreductions::registry::find_variant_entry("ClosestVectorProblem", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["basis"], serde_json::json!([[1, 0], [0, 1]])); - assert_eq!( - data["bounds"], - serde_json::json!([ - {"lower": -10, "upper": 10}, - {"lower": -10, "upper": 10}, - ]) - ); - } - - #[test] - fn test_create_schema_driven_builds_cdft() { - let cli = Cli::parse_from([ - "pred", - "create", - "ConsistencyOfDatabaseFrequencyTables", - "--num-objects", - "6", - "--attribute-domains", - "2,3,2", - "--frequency-tables", - "0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1", - "--known-values", - "0,0,0;3,0,1;1,2,1", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = create_schema_driven( - &args, - "ConsistencyOfDatabaseFrequencyTables", - &BTreeMap::new(), - ) - .expect("schema-driven create should parse") - .expect("schema-driven path should support CDFT"); - - let entry = problemreductions::registry::find_variant_entry( - "ConsistencyOfDatabaseFrequencyTables", - &variant, - ) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["num_objects"], 6); - assert_eq!(data["frequency_tables"][0]["attribute_a"], 0); - assert_eq!(data["known_values"][2]["attribute"], 2); - } - - #[test] - fn test_create_schema_driven_builds_balanced_complete_bipartite_subgraph() { - let cli = Cli::parse_from([ - "pred", - "create", - "BalancedCompleteBipartiteSubgraph", - "--left", - "4", - "--right", - "4", - "--biedges", - "0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3", - "--k", - "3", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let (data, variant) = - create_schema_driven(&args, "BalancedCompleteBipartiteSubgraph", &BTreeMap::new()) - .expect("schema-driven create should parse") - .expect("schema-driven path should support balanced biclique"); - - let entry = problemreductions::registry::find_variant_entry( - "BalancedCompleteBipartiteSubgraph", - &variant, - ) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["graph"]["left_size"], 4); - assert_eq!(data["graph"]["right_size"], 4); - assert_eq!(data["k"], 3); - } - - #[test] - fn test_create_schema_driven_builds_mixed_chinese_postman() { - let cli = Cli::parse_from([ - "pred", - "create", - "MixedChinesePostman/i32", - "--graph", - "0-2,1-3,0-4,4-2", - "--arcs", - "0>1,1>2,2>3,3>0", - "--edge-weights", - "2,3,1,2", - "--arc-weights", - "2,3,1,4", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let resolved_variant = variant_map(&[("weight", "i32")]); - let (data, variant) = create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) - .expect("schema-driven create should parse") - .expect("schema-driven path should support mixed chinese postman"); - - let entry = - problemreductions::registry::find_variant_entry("MixedChinesePostman", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["graph"]["num_vertices"], 5); - assert_eq!(data["arc_weights"], serde_json::json!([2, 3, 1, 4])); - assert_eq!(data["edge_weights"], serde_json::json!([2, 3, 1, 2])); - } - - #[test] - fn test_create_schema_driven_builds_unit_disk_graph_problem_with_default_radius() { - let cli = Cli::parse_from([ - "pred", - "create", - "MIS/UnitDiskGraph", - "--positions", - "0,0;1,0;0.5,0.8", - ]); - - let Commands::Create(args) = cli.command else { - panic!("expected create command"); - }; - - let resolved_variant = variant_map(&[("graph", "UnitDiskGraph"), ("weight", "One")]); - let (data, variant) = - create_schema_driven(&args, "MaximumIndependentSet", &resolved_variant) - .expect("schema-driven create should parse") - .expect("schema-driven path should support UnitDiskGraph variants"); - - let entry = - problemreductions::registry::find_variant_entry("MaximumIndependentSet", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["graph"]["positions"].as_array().unwrap().len(), 3); - assert_eq!( - data["graph"]["edges"], - serde_json::json!([[0, 1], [0, 2], [1, 2]]) - ); - } - - #[test] - fn test_schema_help_example_for_qbf_uses_example_db() { - let example = - schema_help_example_for("QuantifiedBooleanFormulas", &BTreeMap::new()).unwrap(); - assert_eq!( - example, - "--num-vars 2 --quantifiers E,A --clauses \"1,2;1,-2\"" - ); - } - - #[test] - fn test_schema_help_example_for_cbm_uses_json_matrix_syntax() { - let example = - schema_help_example_for("ConsecutiveBlockMinimization", &BTreeMap::new()).unwrap(); - assert!(example.contains("--matrix \"[[false,true,false,false,false,false],[true,false,true,false,false,false],[false,true,false,true,false,false],[false,false,true,false,true,false],[false,false,false,true,false,true],[false,false,false,false,true,false]]\"")); - assert!(example.contains("--bound-k 6")); - } - - #[test] - fn test_problem_help_flag_name_uses_bound_for_grouping_by_swapping_budget() { - assert_eq!( - problem_help_flag_name("GroupingBySwapping", "budget", "usize", false), - "bound" - ); - } - - #[test] - fn test_problem_help_flag_name_preserves_edge_lengths_for_shortest_weight_constrained_path() { - assert_eq!( - problem_help_flag_name( - "ShortestWeightConstrainedPath", - "edge_lengths", - "Vec", - false - ), - "edge-lengths" - ); - } - - #[test] - fn test_problem_help_flag_name_uses_edge_weights_for_longest_circuit_edge_lengths() { - assert_eq!( - problem_help_flag_name("LongestCircuit", "edge_lengths", "Vec", false), - "edge-weights" - ); - } - - #[test] - fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { - let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") - .unwrap_err(); - assert!( - err.to_string().contains("out of range"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_create_scheduling_with_individual_deadlines_accepts_m_alias() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "SchedulingWithIndividualDeadlines", - "--num-tasks", - "3", - "--deadlines", - "1,1,2", - "--m", - "2", - ]) - .expect("parse create command"); - - let Commands::Create(args) = cli.command else { - panic!("expected create subcommand"); - }; - - let out = OutputConfig { - output: Some( - std::env::temp_dir() - .join("pred_test_create_scheduling_with_individual_deadlines_m_alias.json"), - ), - quiet: true, - json: false, - auto_json: false, - }; - create(&args, &out).expect("`--m` should satisfy --num-processors alias"); - - let created: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(out.output.as_ref().unwrap()).unwrap()) - .unwrap(); - std::fs::remove_file(out.output.as_ref().unwrap()).ok(); - - assert_eq!(created["type"], "SchedulingWithIndividualDeadlines"); - assert_eq!(created["data"]["num_processors"], 2); - } - - #[test] - fn test_create_prime_attribute_name_accepts_canonical_flags() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "PrimeAttributeName", - "--universe", - "6", - "--dependencies", - "0,1>2,3,4,5;2,3>0,1,4,5", - "--query-attribute", - "3", - ]) - .expect("parse create command"); - - let Commands::Create(args) = cli.command else { - panic!("expected create subcommand"); - }; - - let output_path = temp_output_path("prime_attribute_name"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).expect("create PrimeAttributeName JSON"); - - let created: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); - fs::remove_file(output_path).ok(); - - assert_eq!(created["type"], "PrimeAttributeName"); - assert_eq!(created["data"]["query_attribute"], 3); - assert_eq!( - created["data"]["dependencies"][0], - serde_json::json!([[0, 1], [2, 3, 4, 5]]) - ); - } - - #[test] - fn test_problem_help_uses_prime_attribute_name_cli_overrides() { - assert_eq!( - problem_help_flag_name("PrimeAttributeName", "num_attributes", "usize", false), - "universe" - ); - assert_eq!( - problem_help_flag_name( - "PrimeAttributeName", - "dependencies", - "Vec<(Vec, Vec)>", - false, - ), - "dependencies" - ); - assert_eq!( - problem_help_flag_name("PrimeAttributeName", "query_attribute", "usize", false), - "query-attribute" - ); - } - - #[test] - fn test_problem_help_uses_problem_specific_lcs_strings_hint() { - assert_eq!( - help_flag_hint( - "LongestCommonSubsequence", - "strings", - "Vec>", - None, - ), - "raw strings: \"ABAC;BACA\" or symbol lists: \"0,1,0;1,0,1\"" - ); - } - - #[test] - fn test_problem_help_uses_string_to_string_correction_cli_flags() { - assert_eq!( - problem_help_flag_name("StringToStringCorrection", "source", "Vec", false), - "source-string" - ); - assert_eq!( - problem_help_flag_name("StringToStringCorrection", "target", "Vec", false), - "target-string" - ); - assert_eq!( - problem_help_flag_name("StringToStringCorrection", "bound", "usize", false), - "bound" - ); - } - - #[test] - fn test_problem_help_keeps_generic_vec_vec_usize_hint_for_other_models() { - assert_eq!( - help_flag_hint("SetBasis", "sets", "Vec>", None), - "semicolon-separated sets: \"0,1;1,2;0,2\"" - ); - } - - #[test] - fn test_problem_help_uses_k_for_staff_scheduling() { - assert_eq!( - help_flag_name("StaffScheduling", "shifts_per_schedule"), - "k" - ); - assert_eq!( - problem_help_flag_name("StaffScheduling", "shifts_per_schedule", "usize", false), - "k" - ); - } - - #[test] - fn test_parse_bool_rows_reports_generic_invalid_boolean_entry() { - let err = parse_bool_rows("1,maybe").unwrap_err().to_string(); - assert_eq!( - err, - "Invalid boolean entry 'maybe': expected 0/1 or true/false" - ); - } - - #[test] - fn test_create_staff_scheduling_outputs_problem_json() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "StaffScheduling", - "--schedules", - "1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1", - "--requirements", - "2,2,2,3,3,2,1", - "--num-workers", - "4", - "--k", - "5", - ]) - .unwrap(); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let output_path = - std::env::temp_dir().join(format!("staff-scheduling-create-{suffix}.json")); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); - assert_eq!(json["type"], "StaffScheduling"); - assert_eq!(json["data"]["num_workers"], 4); - assert_eq!( - json["data"]["requirements"], - serde_json::json!([2, 2, 2, 3, 3, 2, 1]) - ); - std::fs::remove_file(output_path).unwrap(); - } - - #[test] - fn test_create_path_constrained_network_flow_outputs_problem_json() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "PathConstrainedNetworkFlow", - "--arcs", - "0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7", - "--capacities", - "2,1,1,1,1,1,1,1,2,1", - "--source", - "0", - "--sink", - "7", - "--paths", - "0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9", - "--requirement", - "3", - ]) - .expect("parse create command"); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let output_path = temp_output_path("path_constrained_network_flow"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).expect("create PathConstrainedNetworkFlow JSON"); - - let created: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); - fs::remove_file(output_path).ok(); - - assert_eq!(created["type"], "PathConstrainedNetworkFlow"); - assert_eq!(created["data"]["source"], 0); - assert_eq!(created["data"]["sink"], 7); - assert_eq!(created["data"]["requirement"], 3); - assert_eq!(created["data"]["paths"][0], serde_json::json!([0, 2, 5, 8])); - } - - #[test] - fn test_create_path_constrained_network_flow_rejects_invalid_paths() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "PathConstrainedNetworkFlow", - "--arcs", - "0>1,1>2,2>3", - "--capacities", - "1,1,1", - "--source", - "0", - "--sink", - "3", - "--paths", - "0,3", - "--requirement", - "1", - ]) - .expect("parse create command"); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("out of bounds") || err.contains("not contiguous")); - } - - #[test] - fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "StaffScheduling", - "--schedules", - "1,1,1,1,1,0,0;0,1,1,1,1,1", - "--requirements", - "2,2,2,3,3,2,1", - "--num-workers", - "4", - "--k", - "5", - ]) - .unwrap(); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let result = std::panic::catch_unwind(|| create(&args, &out)); - assert!(result.is_ok(), "create should return an error, not panic"); - let err = result.unwrap().unwrap_err().to_string(); - // parse_bool_rows catches ragged rows before validate_staff_scheduling_args - assert!( - err.contains("All rows") || err.contains("schedule 1 has 6 periods, expected 7"), - "expected row-length validation error, got: {err}" - ); - } - - #[test] - fn test_problem_help_uses_num_tasks_for_timetable_design() { - assert_eq!( - problem_help_flag_name("TimetableDesign", "num_tasks", "usize", false), - "num-tasks" - ); - assert_eq!( - help_flag_hint("TimetableDesign", "craftsman_avail", "Vec>", None), - "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" - ); - } - - #[test] - fn test_example_for_path_constrained_network_flow_mentions_paths_flag() { - let example = example_for("PathConstrainedNetworkFlow", None); - assert!(example.contains("--paths")); - assert!(example.contains("--requirement")); - } - - #[test] - fn test_example_for_three_partition_mentions_sizes_and_bound() { - let example = example_for("ThreePartition", None); - assert!(example.contains("--sizes")); - assert!(example.contains("--bound")); - } - - #[test] - fn test_create_three_partition_outputs_problem_json() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "ThreePartition", - "--sizes", - "4,5,6,4,6,5", - "--bound", - "15", - ]) - .expect("parse create command"); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let output_path = temp_output_path("three_partition_create"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).expect("create ThreePartition JSON"); - - let created: serde_json::Value = - serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); - fs::remove_file(output_path).ok(); - - assert_eq!(created["type"], "ThreePartition"); - assert_eq!( - created["data"]["sizes"], - serde_json::json!([4, 5, 6, 4, 6, 5]) - ); - assert_eq!(created["data"]["bound"], 15); - } - - #[test] - fn test_create_three_partition_requires_bound() { - let cli = - Cli::try_parse_from(["pred", "create", "ThreePartition", "--sizes", "4,5,6,4,6,5"]) - .expect("parse create command"); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("ThreePartition requires --bound")); - } - - #[test] - fn test_create_three_partition_rejects_invalid_instance() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "ThreePartition", - "--sizes", - "4,5,6,4,6,5", - "--bound", - "14", - ]) - .expect("parse create command"); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("must equal m * bound")); - } - - #[test] - fn test_create_timetable_design_outputs_problem_json() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "TimetableDesign", - "--num-periods", - "3", - "--num-craftsmen", - "5", - "--num-tasks", - "5", - "--craftsman-avail", - "1,1,1;1,1,0;0,1,1;1,0,1;1,1,1", - "--task-avail", - "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", - "--requirements", - "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", - ]) - .unwrap(); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let output_path = - std::env::temp_dir().join(format!("timetable-design-create-{suffix}.json")); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); - assert_eq!(json["type"], "TimetableDesign"); - assert_eq!(json["data"]["num_periods"], 3); - assert_eq!(json["data"]["num_craftsmen"], 5); - assert_eq!(json["data"]["num_tasks"], 5); - assert_eq!( - json["data"]["craftsman_avail"], - serde_json::json!([ - [true, true, true], - [true, true, false], - [false, true, true], - [true, false, true], - [true, true, true] - ]) - ); - assert_eq!( - json["data"]["task_avail"], - serde_json::json!([ - [true, true, false], - [false, true, true], - [true, false, true], - [true, true, true], - [true, true, true] - ]) - ); - assert_eq!( - json["data"]["requirements"], - serde_json::json!([ - [1, 0, 1, 0, 0], - [0, 1, 0, 0, 1], - [0, 0, 0, 1, 0], - [0, 0, 0, 0, 1], - [0, 1, 0, 0, 0] - ]) - ); - std::fs::remove_file(output_path).unwrap(); - } - - #[test] - fn test_create_timetable_design_reports_invalid_matrix_without_panic() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "TimetableDesign", - "--num-periods", - "3", - "--num-craftsmen", - "5", - "--num-tasks", - "5", - "--craftsman-avail", - "1,1,1;1,1", - "--task-avail", - "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", - "--requirements", - "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", - ]) - .unwrap(); - - let args = match cli.command { - Commands::Create(args) => args, - _ => panic!("expected create command"), - }; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let result = std::panic::catch_unwind(|| create(&args, &out)); - assert!(result.is_ok(), "create should return an error, not panic"); - let err = result.unwrap().unwrap_err().to_string(); - assert!( - err.contains("--craftsman-avail"), - "expected timetable matrix validation error, got: {err}" - ); - assert!(err.contains("Usage: pred create TimetableDesign")); - } - - #[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")); - } - - #[test] - fn test_create_capacity_assignment_serializes_problem_json() { - let output = temp_output_path("capacity_assignment_create"); - let cli = Cli::try_parse_from([ - "pred", - "-o", - output.to_str().unwrap(), - "create", - "CapacityAssignment", - "--capacities", - "1,2,3", - "--cost-matrix", - "1,3,6;2,4,7;1,2,5", - "--delay-matrix", - "8,4,1;7,3,1;6,3,1", - "--delay-budget", - "12", - ]) - .expect("parse create command"); - 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"], "CapacityAssignment"); - assert_eq!(json["data"]["capacities"], serde_json::json!([1, 2, 3])); - assert_eq!(json["data"]["delay_budget"], 12); - } - - #[test] - fn test_create_production_planning_serializes_problem_json() { - let output = temp_output_path("production_planning_create"); - let cli = Cli::try_parse_from([ - "pred", - "-o", - output.to_str().unwrap(), - "create", - "ProductionPlanning", - "--num-periods", - "6", - "--demands", - "5,3,7,2,8,5", - "--capacities", - "12,12,12,12,12,12", - "--setup-costs", - "10,10,10,10,10,10", - "--production-costs", - "1,1,1,1,1,1", - "--inventory-costs", - "1,1,1,1,1,1", - "--cost-bound", - "80", - ]) - .expect("parse create command"); - 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"], "ProductionPlanning"); - assert_eq!(json["data"]["num_periods"], 6); - assert_eq!( - json["data"]["demands"], - serde_json::json!([5, 3, 7, 2, 8, 5]) - ); - assert_eq!( - json["data"]["capacities"], - serde_json::json!([12, 12, 12, 12, 12, 12]) - ); - assert_eq!( - json["data"]["setup_costs"], - serde_json::json!([10, 10, 10, 10, 10, 10]) - ); - assert_eq!( - json["data"]["production_costs"], - serde_json::json!([1, 1, 1, 1, 1, 1]) - ); - assert_eq!( - json["data"]["inventory_costs"], - serde_json::json!([1, 1, 1, 1, 1, 1]) - ); - assert_eq!(json["data"]["cost_bound"], 80); - } - - #[test] - fn test_create_production_planning_requires_all_period_vectors() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "ProductionPlanning", - "--num-periods", - "6", - "--demands", - "5,3,7,2,8,5", - "--capacities", - "12,12,12,12,12,12", - "--setup-costs", - "10,10,10,10,10,10", - "--inventory-costs", - "1,1,1,1,1,1", - "--cost-bound", - "80", - ]) - .expect("parse create command"); - 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("ProductionPlanning requires --production-costs")); - } - - #[test] - fn test_create_production_planning_rejects_mismatched_period_lengths() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "ProductionPlanning", - "--num-periods", - "6", - "--demands", - "5,3,7,2,8", - "--capacities", - "12,12,12,12,12,12", - "--setup-costs", - "10,10,10,10,10,10", - "--production-costs", - "1,1,1,1,1,1", - "--inventory-costs", - "1,1,1,1,1,1", - "--cost-bound", - "80", - ]) - .expect("parse create command"); - 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("--demands must contain exactly 6 entries")); - } - - #[test] - fn test_create_example_production_planning_uses_canonical_example() { - let output = temp_output_path("production_planning_example_create"); - let cli = Cli::try_parse_from([ - "pred", - "-o", - output.to_str().unwrap(), - "create", - "--example", - "ProductionPlanning", - ]) - .expect("parse create command"); - 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"], "ProductionPlanning"); - assert_eq!(json["data"]["num_periods"], 4); - assert_eq!(json["data"]["demands"], serde_json::json!([2, 1, 3, 2])); - assert_eq!(json["data"]["cost_bound"], 16); - } - - #[test] - fn test_create_longest_path_serializes_problem_json() { - let output = temp_output_path("longest_path_create"); - let cli = Cli::try_parse_from([ - "pred", - "-o", - output.to_str().unwrap(), - "create", - "LongestPath", - "--graph", - "0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6", - "--edge-lengths", - "3,2,4,1,5,2,3,2,4,1", - "--source-vertex", - "0", - "--target-vertex", - "6", - ]) - .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"], "LongestPath"); - assert_eq!(json["variant"]["graph"], "SimpleGraph"); - assert_eq!(json["variant"]["weight"], "i32"); - assert_eq!(json["data"]["source_vertex"], 0); - assert_eq!(json["data"]["target_vertex"], 6); - assert_eq!( - json["data"]["edge_lengths"], - serde_json::json!([3, 2, 4, 1, 5, 2, 3, 2, 4, 1]) - ); - } - - #[test] - fn test_create_undirected_flow_lower_bounds_serializes_problem_json() { - let output = temp_output_path("undirected_flow_lower_bounds_create"); - let cli = Cli::try_parse_from([ - "pred", - "-o", - output.to_str().unwrap(), - "create", - "UndirectedFlowLowerBounds", - "--graph", - "0-1,0-2,1-3,2-3,1-4,3-5,4-5", - "--capacities", - "2,2,2,2,1,3,2", - "--lower-bounds", - "1,1,0,0,1,0,1", - "--source", - "0", - "--sink", - "5", - "--requirement", - "3", - ]) - .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"], "UndirectedFlowLowerBounds"); - assert_eq!(json["data"]["source"], 0); - assert_eq!(json["data"]["sink"], 5); - assert_eq!(json["data"]["requirement"], 3); - assert_eq!( - json["data"]["lower_bounds"], - serde_json::json!([1, 1, 0, 0, 1, 0, 1]) - ); - } - - #[test] - fn test_create_capacity_assignment_rejects_non_monotone_cost_row() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "CapacityAssignment", - "--capacities", - "1,2,3", - "--cost-matrix", - "1,3,2;2,4,7;1,2,5", - "--delay-matrix", - "8,4,1;7,3,1;6,3,1", - "--delay-budget", - "12", - ]) - .expect("parse create command"); - 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().to_string(); - assert!(err.contains("cost row 0")); - assert!(err.contains("non-decreasing")); - } - - #[test] - fn test_create_capacity_assignment_rejects_matrix_width_mismatch() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "CapacityAssignment", - "--capacities", - "1,2,3", - "--cost-matrix", - "1,3;2,4,7;1,2,5", - "--delay-matrix", - "8,4,1;7,3,1;6,3,1", - "--delay-budget", - "12", - ]) - .expect("parse create command"); - 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().to_string(); - assert!(err.contains("cost row 0")); - assert!(err.contains("capacities length")); - } - - #[test] - fn test_create_longest_path_requires_edge_lengths() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "LongestPath", - "--graph", - "0-1,1-2", - "--source-vertex", - "0", - "--target-vertex", - "2", - ]) - .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("LongestPath requires --edge-lengths")); - } - - #[test] - fn test_create_longest_path_rejects_weights_flag() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "LongestPath", - "--graph", - "0-1,1-2", - "--weights", - "1,1,1", - "--source-vertex", - "0", - "--target-vertex", - "2", - "--edge-lengths", - "5,7", - ]) - .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("LongestPath uses --edge-lengths, not --weights")); - } - - #[test] - fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "UndirectedFlowLowerBounds", - "--graph", - "0-1,0-2,1-3,2-3,1-4,3-5,4-5", - "--capacities", - "2,2,2,2,1,3,2", - "--source", - "0", - "--sink", - "5", - "--requirement", - "3", - ]) - .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("UndirectedFlowLowerBounds requires --lower-bounds")); - } - - fn empty_args() -> CreateArgs { - CreateArgs { - problem: Some("BiconnectivityAugmentation".to_string()), - example: None, - example_target: None, - example_side: crate::cli::ExampleSide::Source, - graph: None, - weights: None, - edge_weights: None, - edge_lengths: None, - capacities: None, - demands: None, - setup_costs: None, - production_costs: None, - inventory_costs: None, - bundle_capacities: None, - cost_matrix: None, - delay_matrix: None, - lower_bounds: None, - multipliers: None, - source: None, - sink: None, - requirement: None, - num_paths_required: None, - paths: None, - couplings: None, - fields: None, - clauses: None, - disjuncts: None, - num_vars: None, - matrix: None, - k: None, - num_partitions: None, - random: false, - source_vertex: None, - target_vertex: None, - num_vertices: None, - edge_prob: None, - seed: None, - target: None, - m: None, - n: None, - positions: None, - radius: None, - source_1: None, - sink_1: None, - source_2: None, - sink_2: None, - requirement_1: None, - requirement_2: None, - sizes: None, - probabilities: None, - capacity: None, - sequence: None, - sets: None, - r_sets: None, - s_sets: None, - r_weights: None, - s_weights: None, - partition: None, - partitions: None, - bundles: None, - universe: None, - biedges: None, - left: None, - right: None, - rank: None, - basis: None, - target_vec: None, - bounds: None, - release_times: None, - lengths: None, - terminals: None, - terminal_pairs: None, - tree: None, - required_edges: None, - bound: None, - latency_bound: None, - length_bound: None, - weight_bound: None, - diameter_bound: None, - cost_bound: None, - delay_budget: None, - pattern: None, - strings: None, - string: None, - arc_costs: None, - arcs: None, - left_arcs: None, - right_arcs: None, - values: None, - precedences: None, - distance_matrix: None, - potential_edges: None, - budget: None, - max_cycle_length: None, - candidate_arcs: None, - deadlines: None, - precedence_pairs: None, - task_lengths: None, - job_tasks: None, - resource_bounds: None, - resource_requirements: None, - deadline: None, - num_processors: None, - alphabet_size: None, - deps: None, - query: None, - dependencies: None, - num_attributes: None, - source_string: None, - target_string: None, - schedules: None, - requirements: None, - num_workers: None, - num_periods: None, - num_craftsmen: None, - num_tasks: None, - craftsman_avail: None, - task_avail: None, - num_groups: None, - num_sectors: None, - domain_size: None, - relations: None, - conjuncts_spec: None, - relation_attrs: None, - known_keys: None, - num_objects: None, - attribute_domains: None, - frequency_tables: None, - known_values: None, - costs: None, - cut_bound: None, - size_bound: None, - usage: None, - storage: None, - quantifiers: None, - homologous_pairs: None, - pointer_cost: None, - expression: None, - coeff_a: None, - coeff_b: None, - rhs: None, - coeff_c: None, - pairs: None, - required_columns: None, - compilers: None, - setup_times: None, - w_sizes: None, - x_sizes: None, - y_sizes: None, - equations: None, - assignment: None, - initial_marking: None, - output_arcs: None, - gate_types: None, - true_sentences: None, - implications: None, - loop_length: None, - loop_variables: None, - inputs: None, - outputs: None, - assignments: None, - num_variables: None, - truth_table: None, - test_matrix: None, - num_tests: None, - tiles: None, - grid_size: None, - num_colors: None, - } - } - - #[test] - fn test_all_data_flags_empty_treats_potential_edges_as_input() { - let mut args = empty_args(); - args.potential_edges = Some("0-2:3,1-3:5".to_string()); - assert!(!all_data_flags_empty(&args)); - } - - #[test] - fn test_all_data_flags_empty_treats_budget_as_input() { - let mut args = empty_args(); - args.budget = Some("7".to_string()); - assert!(!all_data_flags_empty(&args)); - } - - #[test] - fn test_all_data_flags_empty_treats_max_cycle_length_as_input() { - let mut args = empty_args(); - args.max_cycle_length = Some(4); - assert!(!all_data_flags_empty(&args)); - } - - #[test] - fn test_all_data_flags_empty_treats_homologous_pairs_as_input() { - let mut args = empty_args(); - args.homologous_pairs = Some("2=5;4=3".to_string()); - assert!(!all_data_flags_empty(&args)); - } - - #[test] - fn test_all_data_flags_empty_treats_job_tasks_as_input() { - let mut args = empty_args(); - args.job_tasks = Some("0:1,1:1;1:1,0:1".to_string()); - assert!(!all_data_flags_empty(&args)); - } - - #[test] - fn test_parse_potential_edges() { - let mut args = empty_args(); - args.potential_edges = Some("0-2:3,1-3:5".to_string()); - - let potential_edges = parse_potential_edges(&args).unwrap(); - - assert_eq!(potential_edges, vec![(0, 2, 3), (1, 3, 5)]); - } - - #[test] - fn test_parse_potential_edges_rejects_missing_weight() { - let mut args = empty_args(); - args.potential_edges = Some("0-2,1-3:5".to_string()); - - let err = parse_potential_edges(&args).unwrap_err().to_string(); - - assert!(err.contains("u-v:w")); - } - - #[test] - fn test_parse_budget() { - let mut args = empty_args(); - args.budget = Some("7".to_string()); - - assert_eq!(parse_budget(&args).unwrap(), 7); - } - - #[test] - fn test_create_disjoint_connecting_paths_json() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::graph::DisjointConnectingPaths; - - let mut args = empty_args(); - args.problem = Some("DisjointConnectingPaths".to_string()); - args.graph = Some("0-1,1-3,0-2,1-4,2-4,3-5,4-5".to_string()); - args.terminal_pairs = Some("0-3,2-5".to_string()); - - let output_path = - std::env::temp_dir().join(format!("dcp-create-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "DisjointConnectingPaths"); - assert_eq!( - created.variant, - BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]) - ); - - let problem: DisjointConnectingPaths = - serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.num_vertices(), 6); - assert_eq!(problem.num_edges(), 7); - assert_eq!(problem.terminal_pairs(), &[(0, 3), (2, 5)]); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { - let mut args = empty_args(); - args.problem = Some("DisjointConnectingPaths".to_string()); - args.graph = Some("0-1,1-2,2-3,3-4".to_string()); - args.terminal_pairs = Some("0-2,2-4".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("pairwise disjoint")); - } - - #[test] - fn test_parse_homologous_pairs() { - let mut args = empty_args(); - args.homologous_pairs = Some("2=5;4=3".to_string()); - - assert_eq!(parse_homologous_pairs(&args).unwrap(), vec![(2, 5), (4, 3)]); - } - - #[test] - fn test_parse_homologous_pairs_rejects_invalid_token() { - let mut args = empty_args(); - args.homologous_pairs = Some("2-5".to_string()); - - let err = parse_homologous_pairs(&args).unwrap_err().to_string(); - - assert!(err.contains("u=v")); - } - - #[test] - fn test_parse_graph_respects_explicit_num_vertices() { - let mut args = empty_args(); - args.graph = Some("0-1".to_string()); - args.num_vertices = Some(3); - - let (graph, num_vertices) = parse_graph(&args).unwrap(); - - assert_eq!(num_vertices, 3); - assert_eq!(graph.num_vertices(), 3); - assert_eq!(graph.edges(), vec![(0, 1)]); - } - - #[test] - fn test_validate_potential_edges_rejects_existing_graph_edge() { - let err = validate_potential_edges(&SimpleGraph::path(3), &[(0, 1, 5)]) - .unwrap_err() - .to_string(); - - assert!(err.contains("already exists in the graph")); - } - - #[test] - fn test_validate_potential_edges_rejects_duplicate_edges() { - let err = validate_potential_edges(&SimpleGraph::path(4), &[(0, 3, 1), (3, 0, 2)]) - .unwrap_err() - .to_string(); - - assert!(err.contains("Duplicate potential edge")); - } - - #[test] - fn test_create_biconnectivity_augmentation_json() { - let mut args = empty_args(); - args.graph = Some("0-1,1-2,2-3".to_string()); - args.potential_edges = Some("0-2:3,0-3:4,1-3:2".to_string()); - args.budget = Some("5".to_string()); - - let output_path = std::env::temp_dir().join("pred_test_create_biconnectivity.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["type"], "BiconnectivityAugmentation"); - assert_eq!(json["data"]["budget"], 5); - assert_eq!( - json["data"]["potential_weights"][0], - serde_json::json!([0, 2, 3]) - ); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_biconnectivity_augmentation_json_with_isolated_vertices() { - let mut args = empty_args(); - args.graph = Some("0-1".to_string()); - args.num_vertices = Some(3); - args.potential_edges = Some("1-2:1".to_string()); - args.budget = Some("1".to_string()); - - let output_path = - std::env::temp_dir().join("pred_test_create_biconnectivity_isolated.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - let problem: BiconnectivityAugmentation = - serde_json::from_value(json["data"].clone()).unwrap(); - - assert_eq!(problem.num_vertices(), 3); - assert_eq!(problem.potential_weights(), &[(1, 2, 1)]); - assert_eq!(problem.budget(), &1); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_partial_feedback_edge_set_json() { - use problemreductions::models::graph::PartialFeedbackEdgeSet; - - let mut args = empty_args(); - args.problem = Some("PartialFeedbackEdgeSet".to_string()); - args.graph = Some("0-1,1-2,2-0".to_string()); - args.budget = Some("1".to_string()); - args.max_cycle_length = Some(3); - - let output_path = - std::env::temp_dir().join("pred_test_create_partial_feedback_edge_set.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["type"], "PartialFeedbackEdgeSet"); - assert_eq!(json["data"]["budget"], 1); - assert_eq!(json["data"]["max_cycle_length"], 3); - - let problem: PartialFeedbackEdgeSet = - serde_json::from_value(json["data"].clone()).unwrap(); - assert_eq!(problem.num_vertices(), 3); - assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.budget(), 1); - assert_eq!(problem.max_cycle_length(), 3); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_partial_feedback_edge_set_requires_max_cycle_length() { - let mut args = empty_args(); - args.problem = Some("PartialFeedbackEdgeSet".to_string()); - args.graph = Some("0-1,1-2,2-0".to_string()); - args.budget = Some("1".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("PartialFeedbackEdgeSet requires --max-cycle-length")); - } - - #[test] - fn test_create_ensemble_computation_json() { - let mut args = empty_args(); - args.problem = Some("EnsembleComputation".to_string()); - args.universe = Some(4); - args.sets = Some("0,1,2;0,1,3".to_string()); - args.budget = Some("4".to_string()); - - let output_path = std::env::temp_dir().join("pred_test_create_ensemble_computation.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["type"], "EnsembleComputation"); - assert_eq!(json["data"]["universe_size"], 4); - assert_eq!( - json["data"]["subsets"], - serde_json::json!([[0, 1, 2], [0, 1, 3]]) - ); - assert_eq!(json["data"]["budget"], 4); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_expected_retrieval_cost_json() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::misc::ExpectedRetrievalCost; - - let mut args = empty_args(); - args.problem = Some("ExpectedRetrievalCost".to_string()); - args.probabilities = Some("0.2,0.15,0.15,0.2,0.1,0.2".to_string()); - args.num_sectors = Some(3); - - let output_path = std::env::temp_dir().join(format!( - "expected-retrieval-cost-{}.json", - std::process::id() - )); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "ExpectedRetrievalCost"); - - let problem: ExpectedRetrievalCost = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.num_records(), 6); - assert_eq!(problem.num_sectors(), 3); - use problemreductions::types::Min; - assert!(matches!( - problem.evaluate(&[0, 1, 2, 1, 0, 2]), - Min(Some(_)) - )); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_job_shop_scheduling_json() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::misc::JobShopScheduling; - use problemreductions::traits::Problem; - use problemreductions::types::Min; - - let mut args = empty_args(); - args.problem = Some("JobShopScheduling".to_string()); - args.job_tasks = Some("0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1".to_string()); - - let output_path = - std::env::temp_dir().join(format!("job-shop-scheduling-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "JobShopScheduling"); - assert!(created.variant.is_empty()); - - let problem: JobShopScheduling = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.num_processors(), 2); - assert_eq!(problem.num_jobs(), 5); - assert_eq!( - problem.evaluate(&[0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]), - Min(Some(19)) - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_job_shop_scheduling_requires_job_tasks() { - let mut args = empty_args(); - args.problem = Some("JobShopScheduling".to_string()); - args.num_processors = Some(2); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("JobShopScheduling requires --jobs")); - } - - #[test] - fn test_create_job_shop_scheduling_rejects_malformed_operation() { - let mut args = empty_args(); - args.problem = Some("JobShopScheduling".to_string()); - args.job_tasks = Some("0-3,1:4".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("expected 'processor:length'")); - } - - #[test] - fn test_create_job_shop_scheduling_rejects_consecutive_same_processor() { - let mut args = empty_args(); - args.problem = Some("JobShopScheduling".to_string()); - args.job_tasks = Some("0:1,0:1".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("must use different processors")); - } - - #[test] - fn test_create_rooted_tree_storage_assignment_json() { - let mut args = empty_args(); - args.problem = Some("RootedTreeStorageAssignment".to_string()); - args.universe = Some(5); - args.sets = Some("0,2;1,3;0,4;2,4".to_string()); - args.bound = Some(1); - - let output_path = - std::env::temp_dir().join("pred_test_create_rooted_tree_storage_assignment.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["type"], "RootedTreeStorageAssignment"); - assert_eq!(json["data"]["universe_size"], 5); - assert_eq!( - json["data"]["subsets"], - serde_json::json!([[0, 2], [1, 3], [0, 4], [2, 4]]) - ); - assert_eq!(json["data"]["bound"], 1); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_stacker_crane_json() { - let mut args = empty_args(); - args.problem = Some("StackerCrane".to_string()); - args.num_vertices = Some(6); - args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); - args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); - args.arc_costs = Some("3,4,2,5,3".to_string()); - args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); - - let output_path = std::env::temp_dir().join("pred_test_create_stacker_crane.json"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let content = std::fs::read_to_string(&output_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(json["type"], "StackerCrane"); - assert_eq!(json["data"]["num_vertices"], 6); - assert_eq!(json["data"]["arcs"][0], serde_json::json!([0, 4])); - assert_eq!(json["data"]["edge_lengths"][6], 3); - - std::fs::remove_file(output_path).ok(); - } - - #[test] - fn test_create_stacker_crane_rejects_mismatched_arc_lengths() { - let mut args = empty_args(); - args.problem = Some("StackerCrane".to_string()); - args.num_vertices = Some(6); - args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); - args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); - args.arc_costs = Some("3,4,2,5".to_string()); - args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); - args.bound = Some(20); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("Expected 5 arc costs but got 4")); - } - - #[test] - fn test_create_stacker_crane_rejects_out_of_range_vertices() { - let mut args = empty_args(); - args.problem = Some("StackerCrane".to_string()); - args.num_vertices = Some(5); - args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); - args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); - args.arc_costs = Some("3,4,2,5,3".to_string()); - args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); - args.bound = Some(20); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("--num-vertices (5) is too small for the arcs")); - } - - #[test] - fn test_create_minimum_dummy_activities_pert_json() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::graph::MinimumDummyActivitiesPert; - - let mut args = empty_args(); - args.problem = Some("MinimumDummyActivitiesPert".to_string()); - args.num_vertices = Some(6); - args.arcs = Some("0>2,0>3,1>3,1>4,2>5".to_string()); - - let output_path = temp_output_path("minimum_dummy_activities_pert"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "MinimumDummyActivitiesPert"); - assert!(created.variant.is_empty()); - - let problem: MinimumDummyActivitiesPert = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.num_vertices(), 6); - assert_eq!(problem.num_arcs(), 5); - - let _ = fs::remove_file(output_path); - } - - #[test] - fn test_create_minimum_dummy_activities_pert_rejects_cycles() { - let mut args = empty_args(); - args.problem = Some("MinimumDummyActivitiesPert".to_string()); - args.num_vertices = Some(3); - args.arcs = Some("0>1,1>2,2>0".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("requires the input graph to be a DAG")); - } - - #[test] - fn test_create_balanced_complete_bipartite_subgraph() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::graph::BalancedCompleteBipartiteSubgraph; - - let mut args = empty_args(); - args.problem = Some("BalancedCompleteBipartiteSubgraph".to_string()); - args.biedges = Some("0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3".to_string()); - args.left = Some(4); - args.right = Some(4); - args.k = Some(3); - args.graph = None; - - let output_path = - std::env::temp_dir().join(format!("bcbs-create-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "BalancedCompleteBipartiteSubgraph"); - assert!(created.variant.is_empty()); - - let problem: BalancedCompleteBipartiteSubgraph = - serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.left_size(), 4); - assert_eq!(problem.right_size(), 4); - assert_eq!(problem.num_edges(), 12); - assert_eq!(problem.k(), 3); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_balanced_complete_bipartite_subgraph_rejects_out_of_range_biedges() { - let mut args = empty_args(); - args.problem = Some("BalancedCompleteBipartiteSubgraph".to_string()); - args.biedges = Some("4-0".to_string()); - args.left = Some(4); - args.right = Some(4); - args.k = Some(3); - args.graph = None; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("out of bounds for left partition size 4")); - } - - #[test] - fn test_create_kclique() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::graph::KClique; - - let mut args = empty_args(); - args.problem = Some("KClique".to_string()); - args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string()); - args.k = Some(3); - - let output_path = - std::env::temp_dir().join(format!("kclique-create-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "KClique"); - assert_eq!( - created.variant.get("graph").map(String::as_str), - Some("SimpleGraph") - ); - - let problem: KClique = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.k(), 3); - assert_eq!(problem.num_vertices(), 5); - assert!(problem.evaluate(&[0, 0, 1, 1, 1])); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_kclique_requires_valid_k() { - let mut args = empty_args(); - args.problem = Some("KClique".to_string()); - args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string()); - args.k = None; - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err(); - assert!( - err.to_string().contains("KClique requires --k"), - "unexpected error: {err}" - ); - - args.k = Some(6); - let err = create(&args, &out).unwrap_err(); - assert!( - err.to_string().contains("k must be <= graph num_vertices"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_create_sparse_matrix_compression_json() { - use crate::dispatch::ProblemJsonOutput; - - let mut args = empty_args(); - args.problem = Some("SparseMatrixCompression".to_string()); - args.matrix = Some("1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0".to_string()); - args.bound = Some(2); - - let output_path = - std::env::temp_dir().join(format!("smc-create-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "SparseMatrixCompression"); - assert!(created.variant.is_empty()); - assert_eq!( - created.data, - serde_json::json!({ - "matrix": [ - [true, false, false, true], - [false, true, false, false], - [false, false, true, false], - [true, false, false, false], - ], - "bound_k": 2, - }) - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_sparse_matrix_compression_requires_bound() { - let mut args = empty_args(); - args.problem = Some("SparseMatrixCompression".to_string()); - args.matrix = Some("1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("SparseMatrixCompression requires --matrix and --bound")); - assert!(err.contains("Usage: pred create SparseMatrixCompression")); - } - - #[test] - fn test_create_sparse_matrix_compression_rejects_zero_bound() { - let mut args = empty_args(); - args.problem = Some("SparseMatrixCompression".to_string()); - args.matrix = Some("1,0;0,1".to_string()); - args.bound = Some(0); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("bound >= 1")); - } - - #[test] - fn test_create_graph_partitioning_with_num_partitions() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::graph::GraphPartitioning; - use problemreductions::topology::SimpleGraph; - - let cli = Cli::try_parse_from([ - "pred", - "create", - "GraphPartitioning", - "--graph", - "0-1,1-2,2-3,3-0", - "--num-partitions", - "2", - ]) - .unwrap(); - let args = match cli.command { - Commands::Create(args) => args, - _ => unreachable!(), - }; - - let output_path = temp_output_path("graph-partitioning-create"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "GraphPartitioning"); - let problem: GraphPartitioning = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.num_vertices(), 4); - - let _ = fs::remove_file(output_path); - } - - #[test] - fn test_create_nontautology_with_disjuncts_flag() { - use crate::dispatch::ProblemJsonOutput; - use problemreductions::models::formula::NonTautology; - - let cli = Cli::try_parse_from([ - "pred", - "create", - "NonTautology", - "--num-vars", - "3", - "--disjuncts", - "1,2,3;-1,-2,-3", - ]) - .unwrap(); - let args = match cli.command { - Commands::Create(args) => args, - _ => unreachable!(), - }; - - let output_path = temp_output_path("non-tautology-create"); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "NonTautology"); - let problem: NonTautology = serde_json::from_value(created.data).unwrap(); - assert_eq!(problem.disjuncts(), &[vec![1, 2, 3], vec![-1, -2, -3]]); - - let _ = fs::remove_file(output_path); - } - - #[test] - fn test_create_consecutive_ones_matrix_augmentation_json() { - use crate::dispatch::ProblemJsonOutput; - - let mut args = empty_args(); - args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); - args.matrix = Some("1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0".to_string()); - args.bound = Some(2); - - let output_path = - std::env::temp_dir().join(format!("coma-create-{}.json", std::process::id())); - let out = OutputConfig { - output: Some(output_path.clone()), - quiet: true, - json: false, - auto_json: false, - }; - - create(&args, &out).unwrap(); - - let json = std::fs::read_to_string(&output_path).unwrap(); - let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); - assert_eq!(created.problem_type, "ConsecutiveOnesMatrixAugmentation"); - assert!(created.variant.is_empty()); - assert_eq!( - created.data, - serde_json::json!({ - "matrix": [ - [true, false, false, true, true], - [true, true, false, false, false], - [false, true, true, false, true], - [false, false, true, true, false], - ], - "bound": 2, - }) - ); - - let _ = std::fs::remove_file(output_path); - } - - #[test] - fn test_create_consecutive_ones_matrix_augmentation_requires_bound() { - let mut args = empty_args(); - args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); - args.matrix = Some("1,0;0,1".to_string()); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("ConsecutiveOnesMatrixAugmentation requires --matrix and --bound")); - assert!(err.contains("Usage: pred create ConsecutiveOnesMatrixAugmentation")); - } - - #[test] - fn test_create_consecutive_ones_matrix_augmentation_negative_bound() { - let mut args = empty_args(); - args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); - args.matrix = Some("1,0;0,1".to_string()); - args.bound = Some(-1); - - let out = OutputConfig { - output: None, - quiet: true, - json: false, - auto_json: false, - }; - - let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("nonnegative")); - } -} +#[cfg(test)] +mod tests; diff --git a/problemreductions-cli/src/commands/create/schema_semantics.rs b/problemreductions-cli/src/commands/create/schema_semantics.rs new file mode 100644 index 00000000..3756dbd0 --- /dev/null +++ b/problemreductions-cli/src/commands/create/schema_semantics.rs @@ -0,0 +1,1308 @@ +use super::schema_support::*; +use super::*; + +pub(super) fn validate_schema_driven_semantics( + args: &CreateArgs, + canonical: &str, + resolved_variant: &BTreeMap, + _data: &serde_json::Value, +) -> Result<()> { + match canonical { + "BalancedCompleteBipartiteSubgraph" => { + let usage = "pred create BalancedCompleteBipartiteSubgraph --left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3"; + let _ = parse_bipartite_problem_input( + args, + "BalancedCompleteBipartiteSubgraph", + "balanced biclique size", + usage, + )?; + } + "BiconnectivityAugmentation" => { + let usage = "Usage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let potential_edges = parse_potential_edges(args)?; + validate_potential_edges(&graph, &potential_edges)?; + let _ = parse_budget(args)?; + } + "BoundedComponentSpanningForest" => { + 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 --max-weight 6"; + let (_, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") + })?; + let weights = parse_vertex_weights(args, n)?; + if weights.iter().any(|&weight| weight < 0) { + bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}"); + } + let max_components = args.k.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}") + })?; + if max_components == 0 { + bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); + } + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") + })?; + if bound_raw <= 0 { + bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); + } + let _ = i32::try_from(bound_raw).map_err(|_| { + anyhow::anyhow!( + "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" + ) + })?; + } + "CapacityAssignment" => { + let usage = "Usage: pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; + let capacities_str = args.capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "CapacityAssignment requires --capacities, --cost-matrix, --delay-matrix, and --delay-budget\n\n{usage}" + ) + })?; + let cost_matrix_str = args.cost_matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --cost-matrix\n\n{usage}") + })?; + let delay_matrix_str = args.delay_matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --delay-matrix\n\n{usage}") + })?; + let _ = args.delay_budget.ok_or_else(|| { + anyhow::anyhow!("CapacityAssignment requires --delay-budget\n\n{usage}") + })?; + + let capacities: Vec = util::parse_comma_list(capacities_str)?; + anyhow::ensure!( + !capacities.is_empty(), + "CapacityAssignment requires at least one capacity value\n\n{usage}" + ); + anyhow::ensure!( + capacities.iter().all(|&capacity| capacity > 0), + "CapacityAssignment capacities must be positive\n\n{usage}" + ); + anyhow::ensure!( + capacities.windows(2).all(|w| w[0] < w[1]), + "CapacityAssignment capacities must be strictly increasing\n\n{usage}" + ); + + let cost = parse_u64_matrix_rows(cost_matrix_str, "cost")?; + let delay = parse_u64_matrix_rows(delay_matrix_str, "delay")?; + anyhow::ensure!( + cost.len() == delay.len(), + "cost matrix row count ({}) must match delay matrix row count ({})\n\n{usage}", + cost.len(), + delay.len() + ); + + for (index, row) in cost.iter().enumerate() { + anyhow::ensure!( + row.len() == capacities.len(), + "cost row {} length ({}) must match capacities length ({})\n\n{usage}", + index, + row.len(), + capacities.len() + ); + anyhow::ensure!( + row.windows(2).all(|w| w[0] <= w[1]), + "cost row {} must be non-decreasing\n\n{usage}", + index + ); + } + for (index, row) in delay.iter().enumerate() { + anyhow::ensure!( + row.len() == capacities.len(), + "delay row {} length ({}) must match capacities length ({})\n\n{usage}", + index, + row.len(), + capacities.len() + ); + anyhow::ensure!( + row.windows(2).all(|w| w[0] >= w[1]), + "delay row {} must be non-increasing\n\n{usage}", + index + ); + } + } + "BoyceCoddNormalFormViolation" => { + let n = args.n.ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --n, --sets, and --target\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let sets_str = args.sets.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --sets (functional deps as lhs:rhs;...)\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let target_str = args.target.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ + Usage: pred create BoyceCoddNormalFormViolation --n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + ) + })?; + let _ = parse_bcnf_functional_deps(sets_str, n)?; + let target: Vec = util::parse_comma_list(target_str)?; + ensure_attribute_indices_in_range(&target, n, "Target subset")?; + } + "ClosestVectorProblem" => { + let basis_str = args.basis.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "CVP requires --basis, --target-vec\n\n\ + Usage: pred create CVP --basis \"1,0;0,1\" --target-vec \"0.5,0.5\"" + ) + })?; + let target_str = args + .target_vec + .as_deref() + .ok_or_else(|| anyhow::anyhow!("CVP requires --target-vec (e.g., \"0.5,0.5\")"))?; + let basis: Vec> = basis_str + .split(';') + .map(|row| util::parse_comma_list(row.trim())) + .collect::>>()?; + let target: Vec = util::parse_comma_list(target_str)?; + let n = basis.len(); + let bounds = serde_json::from_value(parse_cvp_bounds_value( + args.bounds.as_deref(), + &CreateContext::default() + .with_field("basis", serde_json::json!(vec![serde_json::json!([0]); n])), + )?)?; + let _ = ClosestVectorProblem::new(basis, target, bounds); + } + "ConsecutiveOnesMatrixAugmentation" => { + let matrix = parse_bool_matrix(args)?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ + Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" + ) + })?; + ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) + .map_err(anyhow::Error::msg)?; + } + "ConsecutiveBlockMinimization" => { + let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; + let matrix_str = args.matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") + })?; + let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + ConsecutiveBlockMinimization::try_new(matrix, bound) + .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?; + } + "ComparativeContainment" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "ComparativeContainment requires --universe, --r-sets, and --s-sets\n\n\ + Usage: pred create ComparativeContainment --universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]" + ) + })?; + let r_sets = parse_named_sets(args.r_sets.as_deref(), "--r-sets")?; + let s_sets = parse_named_sets(args.s_sets.as_deref(), "--s-sets")?; + validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?; + validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?; + match resolved_variant.get("weight").map(|value| value.as_str()) { + Some("One") => { + let r_weights = parse_named_set_weights( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + let s_weights = parse_named_set_weights( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + anyhow::ensure!( + r_weights.iter().all(|&w| w == 1) && s_weights.iter().all(|&w| w == 1), + "Non-unit weights are not supported for ComparativeContainment/One.\n\n\ + Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances." + ); + } + Some("f64") => { + let r_weights = parse_named_set_weights_f64( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; + let s_weights = parse_named_set_weights_f64( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?; + } + Some("i32") | None => { + let r_weights = parse_named_set_weights( + args.r_weights.as_deref(), + r_sets.len(), + "--r-weights", + )?; + validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; + let s_weights = parse_named_set_weights( + args.s_weights.as_deref(), + s_sets.len(), + "--s-weights", + )?; + validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?; + } + Some(other) => bail!( + "Unsupported ComparativeContainment weight variant: {}", + other + ), + } + } + "DisjointConnectingPaths" => { + let usage = + "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_terminal_pairs(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "ExactCoverBy3Sets" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "ExactCoverBy3Sets requires --universe and --sets\n\n\ + Usage: pred create X3C --universe 6 --sets \"0,1,2;3,4,5\"" + ) + })?; + if universe % 3 != 0 { + bail!("Universe size must be divisible by 3, got {}", universe); + } + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + if set.len() != 3 { + bail!( + "Subset {} has {} elements, but X3C requires exactly 3 elements per subset", + i, + set.len() + ); + } + if set[0] == set[1] || set[0] == set[2] || set[1] == set[2] { + bail!("Subset {} contains duplicate elements: {:?}", i, set); + } + for &elem in set { + if elem >= universe { + bail!( + "Subset {} contains element {} which is outside universe of size {}", + i, + elem, + universe + ); + } + } + } + } + "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)?; + anyhow::ensure!( + source != sink, + "GeneralizedHex requires distinct --source and --sink\n\n{usage}" + ); + } + "GroupingBySwapping" => { + let usage = + "Usage: pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 [--alphabet-size 3]"; + let string_str = args.string.as_deref().ok_or_else(|| { + anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") + })?; + let bound = parse_nonnegative_usize_bound( + args.bound.ok_or_else(|| { + anyhow::anyhow!("GroupingBySwapping requires --bound\n\n{usage}") + })?, + "GroupingBySwapping", + usage, + )?; + let string = parse_symbol_list_allow_empty(string_str)?; + let inferred = string.iter().copied().max().map_or(0, |value| value + 1); + let alphabet_size = args.alphabet_size.unwrap_or(inferred); + anyhow::ensure!( + alphabet_size >= inferred, + "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", + alphabet_size, + inferred + ); + anyhow::ensure!( + alphabet_size > 0 || string.is_empty(), + "GroupingBySwapping requires a positive alphabet for non-empty strings.\n\n{usage}" + ); + anyhow::ensure!( + !string.is_empty() || bound == 0, + "GroupingBySwapping requires --bound 0 when --string is empty.\n\n{usage}" + ); + } + "IntegralFlowBundles" => { + let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let bundles = parse_bundles(args, num_arcs, usage)?; + let _ = parse_bundle_capacities(args, bundles.len(), usage)?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") + })?; + let sink = args + .sink + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, graph.num_vertices(), usage)?; + validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; + anyhow::ensure!( + source != sink, + "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" + ); + } + "IntegralFlowHomologousArcs" => { + let usage = "Usage: pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\""; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = if let Some(ref s) = args.capacities { + s.split(',') + .map(|token| { + let trimmed = token.trim(); + trimmed + .parse::() + .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) + }) + .collect::>>()? + } else { + vec![1; num_arcs] + }; + anyhow::ensure!( + capacities.len() == num_arcs, + "Expected {} capacities but got {}\n\n{}", + num_arcs, + capacities.len(), + usage + ); + for (arc_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + anyhow::ensure!( + fits, + "capacity {} at arc index {} is too large for this platform\n\n{}", + capacity, + arc_index, + usage + ); + } + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") + })?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowHomologousArcs requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + let homologous_pairs = + parse_homologous_pairs(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + for &(a, b) in &homologous_pairs { + anyhow::ensure!( + a < num_arcs && b < num_arcs, + "homologous pair ({}, {}) references arc >= num_arcs ({})\n\n{}", + a, + b, + num_arcs, + usage + ); + } + } + "IntegralFlowWithMultipliers" => { + let usage = "Usage: pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities_str = args.capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --capacities\n\n{usage}") + })?; + let capacities: Vec = util::parse_comma_list(capacities_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if capacities.len() != num_arcs { + bail!( + "Expected {} capacities but got {}\n\n{}", + num_arcs, + capacities.len(), + usage + ); + } + for (arc_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at arc index {} is too large for this platform\n\n{}", + capacity, + arc_index, + usage + ); + } + } + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers 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!( + "IntegralFlowWithMultipliers requires distinct --source and --sink\n\n{}", + usage + ); + } + let multipliers_str = args.multipliers.as_deref().ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --multipliers\n\n{usage}") + })?; + let multipliers: Vec = util::parse_comma_list(multipliers_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if multipliers.len() != num_vertices { + bail!( + "Expected {} multipliers but got {}\n\n{}", + num_vertices, + multipliers.len(), + usage + ); + } + if multipliers + .iter() + .enumerate() + .any(|(vertex, &multiplier)| vertex != source && vertex != sink && multiplier == 0) + { + bail!("non-terminal multipliers must be positive\n\n{usage}"); + } + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") + })?; + } + "JobShopScheduling" => { + let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let job_tasks = args + .job_tasks + .as_deref() + .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; + let jobs = parse_job_shop_jobs(job_tasks)?; + let inferred_processors = jobs + .iter() + .flat_map(|job| job.iter().map(|(processor, _)| *processor)) + .max() + .map(|processor| processor + 1); + let num_processors = resolve_processor_count_flags( + "JobShopScheduling", + usage, + args.num_processors, + args.m, + )? + .or(inferred_processors) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty job list; use --num-processors" + ) + })?; + anyhow::ensure!( + num_processors > 0, + "JobShopScheduling requires --num-processors > 0\n\n{usage}" + ); + for (job_index, job) in jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + anyhow::ensure!( + processor < num_processors, + "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" + ); + } + for (task_index, pair) in job.windows(2).enumerate() { + anyhow::ensure!( + pair[0].0 != pair[1].0, + "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", + task_index + 1 + ); + } + } + } + "KClique" => { + let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_kclique_threshold(args.k, graph.num_vertices(), usage)?; + } + "KColoring" => { + let usage = "Usage: pred create KColoring --graph 0-1,1-2,2-0 --k 3"; + let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = util::validate_k_param(resolved_variant, args.k, None, "KColoring") + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "KthBestSpanningTree" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; + let usage = + "Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_edge_weights(args, graph.num_edges())?; + let _ = util::validate_k_param(resolved_variant, args.k, None, "KthBestSpanningTree") + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = args + .bound + .ok_or_else(|| anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}"))? + as i32; + } + "LengthBoundedDisjointPaths" => { + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") + })?; + let _ = validate_length_bounded_disjoint_paths_args( + graph.num_vertices(), + source, + sink, + bound, + Some(usage), + )?; + } + "LongestCommonSubsequence" => { + let usage = + "Usage: pred create LCS --strings \"010110;100101;001011\" [--alphabet-size 2]"; + let strings_str = args.strings.as_deref().ok_or_else(|| { + anyhow::anyhow!("LongestCommonSubsequence requires --strings\n\n{usage}") + })?; + let (strings, inferred_alphabet_size) = parse_lcs_strings(strings_str)?; + let alphabet_size = args.alphabet_size.unwrap_or(inferred_alphabet_size); + anyhow::ensure!( + alphabet_size >= inferred_alphabet_size, + "--alphabet-size {} is smaller than the inferred alphabet size ({})", + alphabet_size, + inferred_alphabet_size + ); + anyhow::ensure!( + strings.iter().any(|string| !string.is_empty()), + "LongestCommonSubsequence requires at least one non-empty string.\n\n{usage}" + ); + anyhow::ensure!( + alphabet_size > 0, + "LongestCommonSubsequence requires a positive alphabet. Provide --alphabet-size when all strings are empty.\n\n{usage}" + ); + } + "LongestPath" => { + let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + if args.weights.is_some() { + bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + } + "MixedChinesePostman" => { + let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; + let graph = parse_mixed_graph(args, usage)?; + let arc_costs = parse_arc_costs(args, graph.num_arcs())?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + if arc_costs.iter().any(|&cost| cost < 0) { + bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); + } + if edge_weights.iter().any(|&weight| weight < 0) { + bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); + } + if resolved_variant.get("weight").map(String::as_str) == Some("One") + && (arc_costs.iter().any(|&cost| cost != 1) + || edge_weights.iter().any(|&weight| weight != 1)) + { + bail!( + "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ + Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." + ); + } + } + "MinMaxMulticenter" => { + let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2"; + let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let vertex_weights = parse_vertex_weights(args, n)?; + let edge_lengths = parse_edge_weights(args, graph.num_edges())?; + let _ = args.k.ok_or_else(|| { + anyhow::anyhow!( + "MinMaxMulticenter requires --k (number of centers)\n\n\ + Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2" + ) + })?; + if vertex_weights.iter().any(|&weight| weight < 0) { + bail!("MinMaxMulticenter --weights must be non-negative"); + } + if edge_lengths.iter().any(|&length| length < 0) { + bail!("MinMaxMulticenter --edge-weights must be non-negative"); + } + } + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" + | "MinimumDominatingSet" + | "MaximalIS" => { + let graph_type = resolved_graph_type(resolved_variant); + let num_vertices = match graph_type { + "KingsSubgraph" | "TriangularSubgraph" => parse_int_positions(args)?.len(), + "UnitDiskGraph" => parse_float_positions(args)?.len(), + _ => { + parse_graph(args) + .map_err(|e| { + anyhow::anyhow!( + "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", + canonical + ) + })? + .1 + } + }; + let weights = parse_vertex_weights(args, num_vertices)?; + reject_nonunit_weights_for_one_variant( + canonical, + graph_type, + resolved_variant, + &weights, + )?; + } + "MinimumHittingSet" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "MinimumHittingSet requires --universe and --sets\n\n\ + Usage: pred create MinimumHittingSet --universe 6 --sets \"0,1,2;0,3,4;1,3,5;2,4,5;0,1,5;2,3;1,4\"" + ) + })?; + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe { + bail!( + "Set {} contains element {} which is outside universe of size {}", + i, + element, + universe + ); + } + } + } + } + "MinimumDummyActivitiesPert" => { + let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("MinimumDummyActivitiesPert requires --arcs\n\n{usage}") + })?; + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + let _ = MinimumDummyActivitiesPert::try_new(graph).map_err(anyhow::Error::msg)?; + } + "MinimumMultiwayCut" => { + let usage = + "Usage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_terminals(args, graph.num_vertices())?; + let _ = parse_edge_weights(args, graph.num_edges())?; + } + "MultipleChoiceBranching" => { + let usage = "Usage: 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\" --threshold 10"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") + })?; + let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let _ = parse_arc_weights(args, num_arcs)?; + let _ = parse_partition_groups(args, num_arcs)?; + let _ = parse_multiple_choice_branching_threshold(args, usage)?; + } + "MultipleCopyFileAllocation" => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + let _ = parse_vertex_i64_values( + args.usage.as_deref(), + "usage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + let _ = parse_vertex_i64_values( + args.storage.as_deref(), + "storage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + } + "MultiprocessorScheduling" => { + let usage = "Usage: pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10"; + let lengths_str = args.lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" + ) + })?; + let num_processors = args.num_processors.ok_or_else(|| { + anyhow::anyhow!("MultiprocessorScheduling requires --num-processors\n\n{usage}") + })?; + anyhow::ensure!( + num_processors > 0, + "MultiprocessorScheduling requires --num-processors > 0\n\n{usage}" + ); + let _ = args.deadline.ok_or_else(|| { + anyhow::anyhow!("MultiprocessorScheduling requires --deadline\n\n{usage}") + })?; + let _: Vec = util::parse_comma_list(lengths_str)?; + } + "PartialFeedbackEdgeSet" => { + 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"; + let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = args + .budget + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") + })? + .parse::() + .map_err(|e| { + anyhow::anyhow!( + "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" + ) + })?; + let _ = args.max_cycle_length.ok_or_else(|| { + anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") + })?; + } + "PathConstrainedNetworkFlow" => { + let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = if let Some(ref s) = args.capacities { + util::parse_comma_list(s)? + } else { + vec![1; num_arcs] + }; + anyhow::ensure!( + capacities.len() == num_arcs, + "capacities length ({}) must match number of arcs ({num_arcs})", + capacities.len() + ); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") + })?; + let _ = args.requirement.ok_or_else(|| { + anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") + })?; + let paths = parse_prescribed_paths(args, num_arcs, usage)?; + validate_prescribed_paths_against_graph(&graph, &paths, source, sink, usage)?; + } + "ProductionPlanning" => { + let usage = "Usage: pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80"; + let num_periods = args.num_periods.ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") + })?; + let demands = parse_named_u64_list( + args.demands.as_deref(), + "ProductionPlanning", + "--demands", + usage, + )?; + let capacities = parse_named_u64_list( + args.capacities.as_deref(), + "ProductionPlanning", + "--capacities", + usage, + )?; + let setup_costs = parse_named_u64_list( + args.setup_costs.as_deref(), + "ProductionPlanning", + "--setup-costs", + usage, + )?; + let production_costs = parse_named_u64_list( + args.production_costs.as_deref(), + "ProductionPlanning", + "--production-costs", + usage, + )?; + let inventory_costs = parse_named_u64_list( + args.inventory_costs.as_deref(), + "ProductionPlanning", + "--inventory-costs", + usage, + )?; + let _ = args.cost_bound.ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --cost-bound\n\n{usage}") + })?; + + for (flag, len) in [ + ("--demands", demands.len()), + ("--capacities", capacities.len()), + ("--setup-costs", setup_costs.len()), + ("--production-costs", production_costs.len()), + ("--inventory-costs", inventory_costs.len()), + ] { + ensure_named_len(len, num_periods, flag, usage)?; + } + } + "SchedulingWithIndividualDeadlines" => { + let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 [--num-processors 3 | --m 3] [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and a processor count (--num-processors or --m)\n\n{usage}" + ) + })?; + let num_tasks = args.num_tasks.or(args.n).ok_or_else(|| { + anyhow::anyhow!( + "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" + ) + })?; + let num_processors = resolve_processor_count_flags( + "SchedulingWithIndividualDeadlines", + usage, + args.num_processors, + args.m, + )? + .ok_or_else(|| { + anyhow::anyhow!( + "SchedulingWithIndividualDeadlines requires --num-processors or --m\n\n{usage}" + ) + })?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; + anyhow::ensure!( + deadlines.len() == num_tasks, + "deadlines length ({}) must equal num_tasks ({})", + deadlines.len(), + num_tasks + ); + for &(pred, succ) in &precedences { + anyhow::ensure!( + pred < num_tasks && succ < num_tasks, + "precedence index out of range: ({}, {}) but num_tasks = {}", + pred, + succ, + num_tasks + ); + } + let _ = SchedulingWithIndividualDeadlines::new( + num_tasks, + num_processors, + deadlines, + precedences, + ); + } + "StringToStringCorrection" => { + let usage = "Usage: pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2"; + let source_str = args.source_string.as_deref().ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") + })?; + let target_str = args.target_string.as_deref().ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") + })?; + let _ = parse_nonnegative_usize_bound( + args.bound.ok_or_else(|| { + anyhow::anyhow!("StringToStringCorrection requires --bound\n\n{usage}") + })?, + "StringToStringCorrection", + usage, + )?; + let source = parse_symbol_list_allow_empty(source_str)?; + let target = parse_symbol_list_allow_empty(target_str)?; + let inferred = source + .iter() + .chain(target.iter()) + .copied() + .max() + .map_or(0, |m| m + 1); + let alphabet_size = args.alphabet_size.unwrap_or(inferred); + anyhow::ensure!( + alphabet_size >= inferred, + "--alphabet-size {} is smaller than max symbol + 1 ({}) in the strings", + alphabet_size, + inferred + ); + } + "SparseMatrixCompression" => { + let matrix = parse_bool_matrix(args)?; + let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" + ) + })?; + let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; + if bound == 0 { + anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); + } + let _ = SparseMatrixCompression::new(matrix, bound); + } + "StackerCrane" => { + let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; + let (arcs_graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let (edges_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + anyhow::ensure!( + edges_graph.num_vertices() == num_vertices, + "internal error: inconsistent graph vertex count" + ); + anyhow::ensure!( + num_vertices == arcs_graph.num_vertices(), + "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" + ); + let arc_lengths = parse_arc_costs(args, num_arcs)?; + let edge_lengths = parse_i32_edge_values( + args.edge_lengths.as_ref(), + edges_graph.num_edges(), + "edge length", + )?; + let _ = problemreductions::models::misc::StackerCrane::try_new( + num_vertices, + arcs_graph.arcs(), + edges_graph.edges(), + arc_lengths, + edge_lengths, + ) + .map_err(|e| anyhow::anyhow!(e))?; + } + "ThreePartition" => { + let sizes_str = args.sizes.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --sizes and --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "ThreePartition requires --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let bound = u64::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "ThreePartition requires a positive integer --bound\n\n\ + Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" + ) + })?; + let sizes: Vec = util::parse_comma_list(sizes_str)?; + let _ = ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?; + } + "UndirectedFlowLowerBounds" => { + let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; + let num_vertices = graph.num_vertices(); + let source = args.source.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") + })?; + let sink = args.sink.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") + })?; + let requirement = args.requirement.ok_or_else(|| { + anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") + })?; + validate_vertex_index("source", source, num_vertices, usage)?; + validate_vertex_index("sink", sink, num_vertices, usage)?; + let _ = UndirectedFlowLowerBounds::new( + graph, + capacities, + lower_bounds, + source, + sink, + requirement, + ); + } + "SequencingToMinimizeMaximumCumulativeCost" => { + let costs_str = args.costs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ + Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" + ) + })?; + let costs: Vec = util::parse_comma_list(costs_str)?; + let precedences = parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?; + validate_precedence_pairs(&precedences, costs.len())?; + } + "SequencingToMinimizeWeightedTardiness" => { + let lengths_str = args.lengths.as_deref().or(args.sizes.as_deref()).ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let weights_str = args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ + Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + ) + })?; + anyhow::ensure!(bound >= 0, "--bound must be non-negative"); + let lengths: Vec = util::parse_comma_list(lengths_str)?; + let weights: Vec = util::parse_comma_list(weights_str)?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + anyhow::ensure!( + lengths.len() == weights.len(), + "lengths length ({}) must equal weights length ({})", + lengths.len(), + weights.len() + ); + anyhow::ensure!( + lengths.len() == deadlines.len(), + "lengths length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + } + "SequencingWithinIntervals" => { + let usage = + "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; + let rt_str = args.release_times.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") + })?; + let dl_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") + })?; + let len_str = args.lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!("SequencingWithinIntervals requires --lengths\n\n{usage}") + })?; + let release_times: Vec = util::parse_comma_list(rt_str)?; + let deadlines: Vec = util::parse_comma_list(dl_str)?; + let lengths: Vec = util::parse_comma_list(len_str)?; + validate_sequencing_within_intervals_inputs( + &release_times, + &deadlines, + &lengths, + usage, + )?; + } + "SetBasis" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "SetBasis requires --universe, --sets, and --k\n\n\ + Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" + ) + })?; + let _ = args.k.ok_or_else(|| { + anyhow::anyhow!( + "SetBasis requires --k\n\n\ + Usage: pred create SetBasis --universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3" + ) + })?; + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe { + bail!( + "Set {} contains element {} which is outside universe of size {}", + i, + element, + universe + ); + } + } + } + } + "ShortestWeightConstrainedPath" => { + let usage = "Usage: pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + if args.weights.is_some() { + bail!( + "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" + ); + } + let edge_lengths_raw = args.edge_lengths.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" + ) + })?; + let edge_weights_raw = args.edge_weights.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" + ) + })?; + let edge_lengths = + parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; + let edge_weights = + parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; + ensure_positive_i32_values(&edge_lengths, "edge lengths")?; + ensure_positive_i32_values(&edge_weights, "edge weights")?; + let source_vertex = args.source_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" + ) + })?; + let target_vertex = args.target_vertex.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" + ) + })?; + let weight_bound = args.weight_bound.ok_or_else(|| { + anyhow::anyhow!( + "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" + ) + })?; + ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; + ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; + ensure_positive_i32(weight_bound, "weight_bound")?; + } + "SteinerTree" => { + let usage = "Usage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let _ = parse_edge_weights(args, graph.num_edges())?; + let _ = parse_terminals(args, graph.num_vertices()) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + } + "TimetableDesign" => { + let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; + let num_periods = args.num_periods.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") + })?; + let num_craftsmen = args.num_craftsmen.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") + })?; + let num_tasks = args.num_tasks.ok_or_else(|| { + anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") + })?; + let craftsman_avail = + parse_named_bool_rows(args.craftsman_avail.as_deref(), "--craftsman-avail", usage)?; + let task_avail = + parse_named_bool_rows(args.task_avail.as_deref(), "--task-avail", usage)?; + let requirements = parse_timetable_requirements(args.requirements.as_deref(), usage)?; + validate_timetable_design_args( + num_periods, + num_craftsmen, + num_tasks, + &craftsman_avail, + &task_avail, + &requirements, + usage, + )?; + } + "UndirectedTwoCommodityIntegralFlow" => { + let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities = parse_capacities(args, graph.num_edges(), usage)?; + for (edge_index, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at edge index {} is too large for this platform\n\n{}", + capacity, + edge_index, + usage + ); + } + } + let num_vertices = graph.num_vertices(); + let source_1 = args.source_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") + })?; + let sink_1 = args.sink_1.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") + })?; + let source_2 = args.source_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") + })?; + let sink_2 = args.sink_2.ok_or_else(|| { + anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") + })?; + let _ = args.requirement_1.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" + ) + })?; + let _ = args.requirement_2.ok_or_else(|| { + anyhow::anyhow!( + "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" + ) + })?; + for (label, vertex) in [ + ("source-1", source_1), + ("sink-1", sink_1), + ("source-2", source_2), + ("sink-2", sink_2), + ] { + validate_vertex_index(label, vertex, num_vertices, usage)?; + } + } + _ => {} + } + + Ok(()) +} diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs new file mode 100644 index 00000000..51a3dec9 --- /dev/null +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -0,0 +1,2519 @@ +use super::*; + +#[derive(Debug, Clone, Default)] +pub(super) struct CreateContext { + num_vertices: Option, + num_edges: Option, + num_arcs: Option, + parsed_fields: BTreeMap, +} + +impl CreateContext { + pub(super) fn with_field(mut self, name: &str, value: serde_json::Value) -> Self { + self.parsed_fields.insert(name.to_string(), value); + self + } + + fn seed_field(&mut self, name: &str, value: T) -> Result<()> { + let value = serde_json::to_value(value)?; + if name == "num_vertices" { + self.num_vertices = value.as_u64().and_then(|raw| usize::try_from(raw).ok()); + } + self.parsed_fields.insert(name.to_string(), value); + Ok(()) + } + + fn usize_field(&self, name: &str) -> Option { + self.parsed_fields + .get(name) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + } + + fn f64_field(&self, name: &str) -> Option { + self.parsed_fields + .get(name) + .and_then(serde_json::Value::as_f64) + } + + fn remember(&mut self, name: &str, concrete_type: &str, value: &serde_json::Value) { + self.parsed_fields.insert(name.to_string(), value.clone()); + + match normalize_type_name(concrete_type).as_str() { + "SimpleGraph" => { + self.num_vertices = value + .get("num_vertices") + .and_then(serde_json::Value::as_u64) + .and_then(|raw| usize::try_from(raw).ok()); + self.num_edges = value + .get("edges") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "DirectedGraph" => { + self.num_vertices = value + .get("num_vertices") + .and_then(serde_json::Value::as_u64) + .and_then(|raw| usize::try_from(raw).ok()); + self.num_arcs = value + .get("arcs") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "KingsSubgraph" | "TriangularSubgraph" => { + self.num_vertices = value + .get("positions") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + "UnitDiskGraph" => { + self.num_vertices = value + .get("positions") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + self.num_edges = value + .get("edges") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + } + _ => {} + } + } +} + +pub(super) fn create_schema_driven( + args: &CreateArgs, + canonical: &str, + resolved_variant: &BTreeMap, +) -> Result)>> { + if !schema_driven_supported_problem(canonical) { + return Ok(None); + } + + let Some(schema) = collect_schemas() + .into_iter() + .find(|schema| schema.name == canonical) + else { + return Ok(None); + }; + let Some(variant_entry) = + problemreductions::registry::find_variant_entry(canonical, resolved_variant) + else { + return Ok(None); + }; + + let graph_type = resolved_graph_type(resolved_variant); + let is_geometry = matches!( + graph_type, + "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" + ); + let flag_map = args.flag_map(); + let mut context = CreateContext::default(); + seed_schema_context_from_cli(args, graph_type, &mut context)?; + validate_schema_driven_semantics(args, canonical, resolved_variant, &serde_json::Value::Null) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; + let mut json_map = serde_json::Map::new(); + + for field in &schema.fields { + let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); + let flag_keys = + schema_field_flag_keys(canonical, &field.name, &field.type_name, is_geometry); + let raw_value = get_schema_flag_value(&flag_map, &flag_keys); + let value = if !schema_field_requires_derived_input(&field.name, &concrete_type) { + if let Some(raw_value) = raw_value.clone() { + match parse_schema_field_value( + args, + canonical, + &concrete_type, + &field.name, + &raw_value, + &context, + ) { + Ok(value) => value, + Err(error) => { + return Err(with_schema_usage(error, canonical, resolved_variant)) + } + } + } else if let Some(derived) = + derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? + { + derived + } else { + return Err(with_schema_usage( + missing_schema_field_error( + canonical, + &field.name, + &field.type_name, + is_geometry, + ), + canonical, + resolved_variant, + )); + } + } else if let Some(derived) = + derive_schema_field_value(args, canonical, &field.name, &concrete_type, &context)? + { + derived + } else if let Some(raw_value) = raw_value { + match parse_schema_field_value( + args, + canonical, + &concrete_type, + &field.name, + &raw_value, + &context, + ) { + Ok(value) => value, + Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), + } + } else { + return Err(with_schema_usage( + missing_schema_field_error(canonical, &field.name, &field.type_name, is_geometry), + canonical, + resolved_variant, + )); + }; + + context.remember(&field.name, &concrete_type, &value); + json_map.insert(field.name.clone(), value); + } + + let data = serde_json::Value::Object(json_map); + validate_schema_driven_semantics(args, canonical, resolved_variant, &data) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; + (variant_entry.factory)(data.clone()).map_err(|error| { + with_schema_usage( + anyhow::anyhow!( + "Schema-driven factory rejected generated data for {canonical}: {error}" + ), + canonical, + resolved_variant, + ) + })?; + + Ok(Some((data, resolved_variant.clone()))) +} + +pub(super) fn missing_schema_field_error( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> anyhow::Error { + let display = problem_help_flag_name(canonical, field_name, field_type, is_geometry); + let flags: Vec = display + .split('/') + .filter_map(|part| { + let trimmed = part.trim().trim_start_matches("--"); + (!trimmed.is_empty()).then(|| format!("--{trimmed}")) + }) + .collect(); + let requirement = match flags.as_slice() { + [] => format!("--{}", field_name.replace('_', "-")), + [flag] => flag.clone(), + [first, second] => format!("{first} or {second}"), + _ => { + let last = flags.last().cloned().unwrap_or_default(); + format!("{}, or {}", flags[..flags.len() - 1].join(", "), last) + } + }; + anyhow::anyhow!("{canonical} requires {requirement}") +} + +pub(super) fn parse_schema_field_value( + args: &CreateArgs, + canonical: &str, + concrete_type: &str, + field_name: &str, + raw: &str, + context: &CreateContext, +) -> Result { + match (canonical, field_name) { + ("BoyceCoddNormalFormViolation", "functional_deps") => { + let num_attributes = args.n.ok_or_else(|| { + anyhow::anyhow!("BoyceCoddNormalFormViolation requires --n, --sets, and --target") + })?; + Ok(serde_json::to_value(parse_bcnf_functional_deps( + raw, + num_attributes, + )?)?) + } + ("BoundedComponentSpanningForest", "max_weight") => { + 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 --max-weight 6"; + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") + })?; + let max_weight = i32::try_from(bound_raw).map_err(|_| { + anyhow::anyhow!( + "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" + ) + })?; + Ok(serde_json::json!(max_weight)) + } + ("ConsecutiveBlockMinimization", "matrix") => { + let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("FeasibleBasisExtension", "matrix") => { + let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("IntegralFlowBundles", "bundle_capacities") => { + let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; + let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let bundles = parse_bundles(args, num_arcs, usage)?; + Ok(serde_json::to_value(parse_bundle_capacities( + args, + bundles.len(), + usage, + )?)?) + } + ("IntegralFlowHomologousArcs", "homologous_pairs") => { + Ok(serde_json::to_value(parse_homologous_pairs(args)?)?) + } + ("LengthBoundedDisjointPaths", "max_length") => { + let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") + })?; + let max_length = usize::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}" + ) + })?; + Ok(serde_json::json!(max_length)) + } + ("LongestCommonSubsequence", "strings") => { + let (strings, _) = parse_lcs_strings(raw)?; + Ok(serde_json::to_value(strings)?) + } + ("MinimumDecisionTree", "test_matrix") => { + let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumDecisionTree requires --test-matrix as a JSON 2D bool array\n\n{usage}\n\nFailed to parse --test-matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("MinimumWeightDecoding", "matrix") => { + let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("MinimumWeightSolutionToLinearEquations", "matrix") => { + let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; + let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { + anyhow::anyhow!( + "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" + ) + })?; + Ok(serde_json::to_value(matrix)?) + } + ("GroupingBySwapping", "string") + | ("StringToStringCorrection", "source") + | ("StringToStringCorrection", "target") => { + Ok(serde_json::to_value(parse_symbol_list_allow_empty(raw)?)?) + } + ("MultipleCopyFileAllocation", "usage") => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + Ok(serde_json::to_value(parse_vertex_i64_values( + args.usage.as_deref(), + "usage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?)?) + } + ("MultipleCopyFileAllocation", "storage") => { + let (_, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + Ok(serde_json::to_value(parse_vertex_i64_values( + args.storage.as_deref(), + "storage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?)?) + } + ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => { + Ok(serde_json::to_value(parse_precedence_pairs( + args.precedences + .as_deref() + .or(args.precedence_pairs.as_deref()), + )?)?) + } + ("UndirectedTwoCommodityIntegralFlow", "capacities") => { + let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + Ok(serde_json::to_value(parse_capacities( + args, + graph.num_edges(), + usage, + )?)?) + } + _ => parse_field_value(concrete_type, field_name, raw, context), + } +} + +pub(super) fn schema_driven_supported_problem(canonical: &str) -> bool { + canonical != "ILP" && canonical != "CircuitSAT" +} + +pub(super) fn schema_field_flag_keys( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> Vec { + let mut keys = vec![field_name.replace('_', "-")]; + for display_key in problem_help_flag_name(canonical, field_name, field_type, is_geometry) + .split('/') + .map(|key| key.trim().trim_start_matches("--").to_string()) + .filter(|key| !key.is_empty()) + { + if !keys.contains(&display_key) { + keys.push(display_key); + } + } + keys +} + +pub(super) fn get_schema_flag_value( + flag_map: &std::collections::HashMap<&'static str, Option>, + keys: &[String], +) -> Option { + keys.iter() + .find_map(|key| flag_map.get(key.as_str()).cloned().flatten()) +} + +pub(super) fn resolve_schema_field_type( + type_name: &str, + resolved_variant: &BTreeMap, +) -> String { + let normalized = normalize_type_name(type_name); + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .unwrap_or("SimpleGraph"); + let weight_type = resolved_variant + .get("weight") + .map(String::as_str) + .unwrap_or("One"); + + match normalized.as_str() { + "G" => graph_type.to_string(), + "W" => weight_type.to_string(), + "W::Sum" => weight_sum_type(weight_type).to_string(), + "Vec" => format!("Vec<{weight_type}>"), + "Vec>" => format!("Vec>"), + "Vec<(usize,usize,W)>" => format!("Vec<(usize,usize,{weight_type})>"), + "Vec>" => format!("Vec>"), + other => other.to_string(), + } +} + +pub(super) fn weight_sum_type(weight_type: &str) -> &'static str { + match weight_type { + "One" | "i32" => "i32", + "f64" => "f64", + _ => "i32", + } +} + +pub(super) fn seed_schema_context_from_cli( + args: &CreateArgs, + graph_type: &str, + context: &mut CreateContext, +) -> Result<()> { + if let Some(num_vertices) = args.num_vertices { + context.seed_field("num_vertices", num_vertices)?; + } + if graph_type == "UnitDiskGraph" { + context.seed_field("radius", args.radius.unwrap_or(1.0))?; + } + Ok(()) +} + +pub(super) fn derive_schema_field_value( + args: &CreateArgs, + canonical: &str, + field_name: &str, + concrete_type: &str, + context: &CreateContext, +) -> Result> { + if let Some(defaulted) = + derive_schema_default_value(canonical, field_name, concrete_type, context)? + { + return Ok(Some(defaulted)); + } + + if field_name == "graph" && concrete_type == "MixedGraph" { + let usage = format!( + "Usage: pred create {canonical} {}", + example_for(canonical, None) + ); + return Ok(Some(serde_json::to_value(parse_mixed_graph( + args, &usage, + )?)?)); + } + + if field_name == "graph" && concrete_type == "BipartiteGraph" { + let left = args + .left + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; + let right = args + .right + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; + let edges_raw = args + .biedges + .as_deref() + .ok_or_else(|| anyhow::anyhow!("{canonical} requires --biedges"))?; + let edges = util::parse_edge_pairs(edges_raw)?; + validate_bipartite_edges(canonical, left, right, &edges)?; + return Ok(Some(serde_json::to_value(BipartiteGraph::new( + left, right, edges, + ))?)); + } + + if canonical == "ClosestVectorProblem" + && field_name == "bounds" + && normalize_type_name(concrete_type) == "Vec" + { + return Ok(Some(parse_cvp_bounds_value( + args.bounds.as_deref(), + context, + )?)); + } + + if canonical == "ConjunctiveBooleanQuery" + && field_name == "num_variables" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args + .conjuncts_spec + .as_deref() + .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec"))?; + return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); + } + + if canonical == "GroupingBySwapping" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args + .string + .as_deref() + .ok_or_else(|| anyhow::anyhow!("GroupingBySwapping requires --string"))?; + let string = parse_symbol_list_allow_empty(raw)?; + let inferred = string.iter().copied().max().map_or(0, |value| value + 1); + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred)))); + } + + if canonical == "JobShopScheduling" + && field_name == "num_processors" + && normalize_type_name(concrete_type) == "usize" + { + let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; + let inferred_processors = match args.job_tasks.as_deref() { + Some(job_tasks) => { + let jobs = parse_job_shop_jobs(job_tasks)?; + jobs.iter() + .flat_map(|job| job.iter().map(|(processor, _)| *processor)) + .max() + .map(|processor| processor + 1) + } + None => None, + }; + let num_processors = + resolve_processor_count_flags("JobShopScheduling", usage, args.num_processors, args.m)? + .or(inferred_processors) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty job list; use --num-processors" + ) + })?; + return Ok(Some(serde_json::json!(num_processors))); + } + + if canonical == "LongestCommonSubsequence" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let raw = args + .strings + .as_deref() + .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; + let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred_alphabet_size)))); + } + + if canonical == "LongestCommonSubsequence" + && field_name == "max_length" + && normalize_type_name(concrete_type) == "usize" + { + let strings: Vec> = + serde_json::from_value(context.parsed_fields.get("strings").cloned().ok_or_else( + || anyhow::anyhow!("LCS max_length derivation requires parsed strings"), + )?)?; + let max_length = strings.iter().map(Vec::len).min().unwrap_or(0); + return Ok(Some(serde_json::json!(max_length))); + } + + if canonical == "QUBO" + && field_name == "num_vars" + && normalize_type_name(concrete_type) == "usize" + { + let matrix = parse_matrix(args)?; + return Ok(Some(serde_json::json!(matrix.len()))); + } + + if canonical == "StringToStringCorrection" + && field_name == "alphabet_size" + && normalize_type_name(concrete_type) == "usize" + { + let source = parse_symbol_list_allow_empty(args.source_string.as_deref().unwrap_or(""))?; + let target = parse_symbol_list_allow_empty(args.target_string.as_deref().unwrap_or(""))?; + let inferred = source + .iter() + .chain(target.iter()) + .copied() + .max() + .map_or(0, |value| value + 1); + return Ok(Some(serde_json::json!(args + .alphabet_size + .unwrap_or(inferred)))); + } + + if field_name == "precedences" + && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" + && args.precedences.is_none() + && args.precedence_pairs.is_none() + { + return Ok(Some(serde_json::json!([]))); + } + + if canonical == "ComparativeContainment" + && matches!(field_name, "r_weights" | "s_weights") + && matches!( + normalize_type_name(concrete_type).as_str(), + "Vec" | "Vec" | "Vec" + ) + { + let sets_len = context + .parsed_fields + .get(match field_name { + "r_weights" => "r_sets", + _ => "s_sets", + }) + .and_then(serde_json::Value::as_array) + .map(Vec::len); + if let Some(len) = sets_len { + let value = match normalize_type_name(concrete_type).as_str() { + "Vec" | "Vec" => serde_json::json!(vec![1_i32; len]), + "Vec" => serde_json::json!(vec![1.0_f64; len]), + _ => unreachable!(), + }; + return Ok(Some(value)); + } + } + + if canonical == "ConsistencyOfDatabaseFrequencyTables" + && field_name == "known_values" + && normalize_type_name(concrete_type) == "Vec" + && args.known_values.is_none() + { + return Ok(Some(serde_json::json!([]))); + } + + if canonical == "LengthBoundedDisjointPaths" + && field_name == "max_paths" + && normalize_type_name(concrete_type) == "usize" + { + let graph_value = context.parsed_fields.get("graph").cloned(); + let source = context.usize_field("source"); + let sink = context.usize_field("sink"); + if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { + let graph: SimpleGraph = + serde_json::from_value(graph_value).context("Failed to deserialize graph")?; + let max_paths = graph + .neighbors(source) + .len() + .min(graph.neighbors(sink).len()); + return Ok(Some(serde_json::json!(max_paths))); + } + } + + Ok(None) +} + +pub(super) fn derive_schema_default_value( + canonical: &str, + field_name: &str, + concrete_type: &str, + context: &CreateContext, +) -> Result> { + let normalized = normalize_type_name(concrete_type); + + let one_list = |len: usize| match normalized.as_str() { + "Vec" | "Vec" => Some(serde_json::json!(vec![1_i32; len])), + "Vec" => Some(serde_json::json!(vec![1_u64; len])), + "Vec" => Some(serde_json::json!(vec![1_i64; len])), + "Vec" => Some(serde_json::json!(vec![1_usize; len])), + "Vec" => Some(serde_json::json!(vec![1.0_f64; len])), + _ => None, + }; + + let derived = match field_name { + "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), + "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), + "arc_weights" | "arc_lengths" if context.num_arcs.is_some() => { + context.num_arcs.and_then(one_list) + } + "capacities" if canonical == "PathConstrainedNetworkFlow" => { + context.num_arcs.and_then(one_list) + } + "couplings" if canonical == "SpinGlass" => context.num_edges.and_then(one_list), + "fields" if canonical == "SpinGlass" => match normalized.as_str() { + "Vec" => context + .num_vertices + .map(|len| serde_json::json!(vec![0_i32; len])), + "Vec" => context + .num_vertices + .map(|len| serde_json::json!(vec![0.0_f64; len])), + _ => None, + }, + _ => None, + }; + + Ok(derived) +} + +pub(super) fn schema_field_requires_derived_input(field_name: &str, concrete_type: &str) -> bool { + field_name == "graph" && matches!(concrete_type, "MixedGraph" | "BipartiteGraph") +} + +pub(super) fn with_schema_usage( + error: anyhow::Error, + canonical: &str, + resolved_variant: &BTreeMap, +) -> anyhow::Error { + let message = error.to_string(); + if message.contains("Usage: pred create") { + return error; + } + let graph_type = resolved_variant.get("graph").map(String::as_str); + anyhow::anyhow!( + "{message}\n\nUsage: pred create {canonical} {}", + example_for(canonical, graph_type) + ) +} + +pub(super) fn parse_field_value( + concrete_type: &str, + field_name: &str, + raw: &str, + context: &CreateContext, +) -> Result { + let normalized_type = normalize_type_name(concrete_type); + let value = match normalized_type.as_str() { + "SimpleGraph" => parse_simple_graph_value(raw, context)?, + "DirectedGraph" => parse_directed_graph_value(raw, context)?, + "KingsSubgraph" => parse_grid_subgraph_value(raw, true)?, + "TriangularSubgraph" => parse_grid_subgraph_value(raw, false)?, + "UnitDiskGraph" => parse_unit_disk_graph_value(raw, context)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_numeric_list_value::(raw)?, + "Vec" => parse_bool_list_value(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_nested_numeric_list_value::(raw)?, + "Vec>" => parse_bool_rows_value(raw, field_name)?, + "Vec>>" => parse_3d_numeric_list_value::(raw)?, + "Vec>>" => parse_3d_numeric_list_value::(raw)?, + "Vec<[usize;3]>" => parse_triple_array_list_value(raw)?, + "Vec" => serde_json::to_value(parse_clauses_raw(raw)?)?, + "Vec<(usize,usize)>" => parse_pair_list_value(raw)?, + "Vec<(u64,u64)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,f64)>" => parse_indexed_numeric_pairs_value::(raw)?, + "Vec<(usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,usize,usize,usize)>" => parse_semicolon_tuple_list_value::(raw)?, + "Vec<(usize,usize,One)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,i32)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,i64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,u64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(usize,usize,f64)>" => parse_weighted_edge_list_value::(raw)?, + "Vec<(Vec,Vec)>" => serde_json::to_value(parse_dependencies(raw)?)?, + "Vec<(Vec,usize)>" => serde_json::to_value(parse_implications(raw)?)?, + "Vec<(usize,Vec)>" => serde_json::to_value(parse_cbq_conjuncts(raw, context)?)?, + "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, + "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, + "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, + "Vec" => { + serde_json::to_value(parse_cdft_frequency_tables_value(raw, context)?)? + } + "Vec" => serde_json::to_value(parse_cdft_known_values_value(raw, context)?)?, + "Vec" => serde_json::to_value(parse_cbq_relations(raw, context)?)?, + "Vec" => parse_string_list_value(raw)?, + "Vec" => parse_cvp_bounds_value(Some(raw), context)?, + "Vec" => parse_biguint_list_value(raw)?, + "BigUint" => parse_biguint_value(raw)?, + "Vec>" => parse_optional_bool_list_value(raw)?, + "Vec" => serde_json::to_value(parse_quantifiers_raw(raw, context)?)?, + "IntExpr" => parse_json_passthrough_value(raw)?, + "bool" => serde_json::to_value(parse_bool_token(raw.trim())?)?, + "One" => serde_json::json!(1), + "usize" => parse_scalar_value::(raw)?, + "u64" => parse_scalar_value::(raw)?, + "i32" => parse_scalar_value::(raw)?, + "i64" => parse_scalar_value::(raw)?, + "f64" => parse_scalar_value::(raw)?, + other => bail!("Unsupported schema parser for field '{field_name}' with type '{other}'"), + }; + + Ok(value) +} + +pub(super) fn normalize_type_name(type_name: &str) -> String { + type_name.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +pub(super) fn parse_scalar_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + Ok(serde_json::to_value(raw.trim().parse::().map_err( + |err| anyhow::anyhow!("Invalid value '{}': {err}", raw.trim()), + )?)?) +} + +pub(super) fn parse_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + Ok(serde_json::to_value(util::parse_comma_list::(raw)?)?) +} + +pub(super) fn parse_bool_list_value(raw: &str) -> Result { + let values: Vec = raw + .split(',') + .map(|entry| parse_bool_token(entry.trim())) + .collect::>()?; + Ok(serde_json::to_value(values)?) +} + +pub(super) fn parse_bool_rows_value(raw: &str, field_name: &str) -> Result { + let flag = format!("--{}", field_name.replace('_', "-")); + let rows = parse_bool_rows(raw) + .map_err(|err| anyhow::anyhow!("{}", err.to_string().replace("--matrix", &flag)))?; + Ok(serde_json::to_value(rows)?) +} + +pub(super) fn parse_nested_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let rows: Vec> = raw + .split(';') + .map(|row| util::parse_comma_list::(row.trim())) + .collect::>()?; + Ok(serde_json::to_value(rows)?) +} + +pub(super) fn parse_3d_numeric_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let matrices: Vec>> = raw + .split('|') + .map(|matrix| { + matrix + .split(';') + .map(|row| util::parse_comma_list::(row.trim())) + .collect::>>() + }) + .collect::>()?; + Ok(serde_json::to_value(matrices)?) +} + +pub(super) fn parse_triple_array_list_value(raw: &str) -> Result { + let triples: Vec<[usize; 3]> = raw + .split(';') + .map(|entry| { + let values: Vec = util::parse_comma_list(entry.trim())?; + anyhow::ensure!( + values.len() == 3, + "Expected triple with exactly 3 entries, got {}", + values.len() + ); + Ok([values[0], values[1], values[2]]) + }) + .collect::>()?; + Ok(serde_json::to_value(triples)?) +} + +pub(super) fn parse_clauses_raw(raw: &str) -> Result> { + raw.split(';') + .map(|clause| { + let literals: Vec = clause + .trim() + .split(',') + .map(|value| value.trim().parse::()) + .collect::, _>>()?; + Ok(CNFClause::new(literals)) + }) + .collect() +} + +pub(super) fn parse_pair_list_value(raw: &str) -> Result { + let pairs: Vec<(usize, usize)> = raw + .split(',') + .map(|entry| { + let entry = entry.trim(); + let parts: Vec<&str> = if entry.contains('>') { + entry.split('>').collect() + } else { + entry.split('-').collect() + }; + anyhow::ensure!( + parts.len() == 2, + "Invalid pair '{entry}': expected u-v or u>v" + ); + Ok(( + parts[0].trim().parse::()?, + parts[1].trim().parse::()?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(pairs)?) +} + +pub(super) fn infer_cbq_num_variables(raw: &str) -> Result { + let mut num_vars = 0usize; + for conjunct in raw.split(';').filter(|entry| !entry.trim().is_empty()) { + let (_, args_str) = conjunct.trim().split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid conjunct format: expected 'rel_idx:args', got '{}'", + conjunct.trim() + ) + })?; + for arg in args_str + .split(',') + .map(str::trim) + .filter(|arg| !arg.is_empty()) + { + if let Some(rest) = arg.strip_prefix('v') { + let index: usize = rest + .parse() + .map_err(|err| anyhow::anyhow!("Invalid variable index '{rest}': {err}"))?; + num_vars = num_vars.max(index + 1); + } + } + } + Ok(num_vars) +} + +pub(super) fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result> { + let domain_size = context.usize_field("domain_size").ok_or_else(|| { + anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field") + })?; + + raw.split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|rel_str| { + let rel_str = rel_str.trim(); + let (arity_str, tuples_str) = rel_str.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid relation format: expected 'arity:tuples', got '{rel_str}'") + })?; + let arity: usize = arity_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid arity '{arity_str}': {e}"))?; + let tuples: Vec> = if tuples_str.trim().is_empty() { + Vec::new() + } else { + tuples_str + .split('|') + .filter(|tuple| !tuple.trim().is_empty()) + .map(|tuple| { + let tuple: Vec = util::parse_comma_list(tuple.trim())?; + anyhow::ensure!( + tuple.len() == arity, + "Relation tuple has {} entries, expected arity {arity}", + tuple.len() + ); + for &value in &tuple { + anyhow::ensure!( + value < domain_size, + "Tuple value {value} >= domain-size {domain_size}" + ); + } + Ok(tuple) + }) + .collect::>()? + }; + Ok(CbqRelation { arity, tuples }) + }) + .collect() +} + +pub(super) fn parse_cbq_conjuncts( + raw: &str, + context: &CreateContext, +) -> Result)>> { + let relations: Vec = + serde_json::from_value(context.parsed_fields.get("relations").cloned().ok_or_else( + || anyhow::anyhow!("CBQ conjunct parsing requires prior relations field"), + )?) + .context("Failed to deserialize parsed CBQ relations")?; + let domain_size = context + .usize_field("domain_size") + .ok_or_else(|| anyhow::anyhow!("CBQ conjunct parsing requires prior domain_size field"))?; + let num_variables = context.usize_field("num_variables").unwrap_or(0); + + raw.split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|conj_str| { + let conj_str = conj_str.trim(); + let (idx_str, args_str) = conj_str.split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid conjunct format: expected 'rel_idx:args', got '{conj_str}'" + ) + })?; + let rel_idx: usize = idx_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid relation index '{idx_str}': {e}"))?; + anyhow::ensure!( + rel_idx < relations.len(), + "Conjunct references relation {rel_idx}, but only {} relations exist", + relations.len() + ); + + let query_args: Vec = args_str + .split(',') + .map(|arg| { + let arg = arg.trim(); + if let Some(rest) = arg.strip_prefix('v') { + let variable: usize = rest + .parse() + .map_err(|e| anyhow::anyhow!("Invalid variable index '{rest}': {e}"))?; + anyhow::ensure!( + variable < num_variables, + "Variable({variable}) >= num_variables ({num_variables})" + ); + Ok(QueryArg::Variable(variable)) + } else if let Some(rest) = arg.strip_prefix('c') { + let constant: usize = rest + .parse() + .map_err(|e| anyhow::anyhow!("Invalid constant value '{rest}': {e}"))?; + anyhow::ensure!( + constant < domain_size, + "Constant {constant} >= domain-size {domain_size}" + ); + Ok(QueryArg::Constant(constant)) + } else { + Err(anyhow::anyhow!( + "Invalid query arg '{arg}': expected vN (variable) or cN (constant)" + )) + } + }) + .collect::>()?; + anyhow::ensure!( + query_args.len() == relations[rel_idx].arity, + "Conjunct has {} args, but relation {rel_idx} has arity {}", + query_args.len(), + relations[rel_idx].arity + ); + Ok((rel_idx, query_args)) + }) + .collect() +} + +pub(super) fn parse_semicolon_tuple_list_value( + raw: &str, +) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let tuples: Vec> = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let values: Vec = util::parse_comma_list(entry.trim())?; + anyhow::ensure!( + values.len() == N, + "Expected tuple with {N} entries, got {}", + values.len() + ); + Ok(values) + }) + .collect::>()?; + Ok(serde_json::to_value(tuples)?) +} + +pub(super) fn parse_weighted_edge_list_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let edges: Vec<(usize, usize, T)> = raw + .split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid weighted edge '{entry}': expected u-v:w") + })?; + let (u_str, v_str) = if let Some((u, v)) = edge_part.split_once('-') { + (u, v) + } else if let Some((u, v)) = edge_part.split_once('>') { + (u, v) + } else { + bail!("Invalid weighted edge '{entry}': expected u-v:w or u>v:w"); + }; + Ok(( + u_str.trim().parse::()?, + v_str.trim().parse::()?, + weight_part.trim().parse::().map_err(|err| { + anyhow::anyhow!("Invalid edge weight '{}': {err}", weight_part.trim()) + })?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(edges)?) +} + +pub(super) fn parse_indexed_numeric_pairs_value(raw: &str) -> Result +where + T: std::str::FromStr + Serialize, + T::Err: std::fmt::Display, +{ + let pairs: Vec<(usize, T)> = + raw.split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (index, value) = entry.split_once(':').ok_or_else(|| { + anyhow::anyhow!("Invalid pair '{entry}': expected index:value") + })?; + Ok(( + index.trim().parse::()?, + value.trim().parse::().map_err(|err| { + anyhow::anyhow!("Invalid value '{}': {err}", value.trim()) + })?, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(pairs)?) +} + +pub(super) fn parse_indexed_usize_lists_value(raw: &str) -> Result { + let entries: Vec<(usize, Vec)> = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + let entry = entry.trim(); + let (index, values) = entry + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("Invalid entry '{entry}': expected index:values"))?; + Ok(( + index.trim().parse::()?, + if values.trim().is_empty() { + Vec::new() + } else { + util::parse_comma_list(values.trim())? + }, + )) + }) + .collect::>()?; + Ok(serde_json::to_value(entries)?) +} + +pub(super) fn parse_string_list_value(raw: &str) -> Result { + let values: Vec = raw + .split(';') + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| entry.trim().to_string()) + .collect(); + Ok(serde_json::to_value(values)?) +} + +pub(super) fn parse_symbol_list_allow_empty(raw: &str) -> Result> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(Vec::new()); + } + raw.split(',') + .map(|value| { + value + .trim() + .parse::() + .context("invalid symbol index") + }) + .collect() +} + +pub(super) fn parse_lcs_strings(raw: &str) -> Result<(Vec>, usize)> { + let segments: Vec<&str> = raw.split(';').map(str::trim).collect(); + let comma_mode = segments.iter().any(|segment| segment.contains(',')); + + if comma_mode { + let strings = segments + .iter() + .map(|segment| parse_symbol_list_allow_empty(segment)) + .collect::>>()?; + let inferred_alphabet_size = strings + .iter() + .flat_map(|string| string.iter()) + .copied() + .max() + .map(|value| value + 1) + .unwrap_or(0); + return Ok((strings, inferred_alphabet_size)); + } + + let mut encoding = BTreeMap::new(); + let mut next_symbol = 0usize; + let strings = segments + .iter() + .map(|segment| { + segment + .as_bytes() + .iter() + .map(|byte| { + let entry = encoding.entry(*byte).or_insert_with(|| { + let current = next_symbol; + next_symbol += 1; + current + }); + *entry + }) + .collect::>() + }) + .collect::>(); + Ok((strings, next_symbol)) +} + +pub(super) fn parse_bcnf_functional_deps( + raw: &str, + num_attributes: usize, +) -> Result, Vec)>> { + raw.split(';') + .map(|fd_str| { + let parts: Vec<&str> = fd_str.split(':').collect(); + anyhow::ensure!( + parts.len() == 2, + "Each FD must be lhs:rhs, got '{}'", + fd_str + ); + let lhs: Vec = util::parse_comma_list(parts[0])?; + let rhs: Vec = util::parse_comma_list(parts[1])?; + ensure_attribute_indices_in_range( + &lhs, + num_attributes, + &format!("Functional dependency '{fd_str}' lhs"), + )?; + ensure_attribute_indices_in_range( + &rhs, + num_attributes, + &format!("Functional dependency '{fd_str}' rhs"), + )?; + Ok((lhs, rhs)) + }) + .collect() +} + +pub(super) fn parse_cdft_frequency_tables_value( + raw: &str, + context: &CreateContext, +) -> Result> { + let attribute_domains: Vec = serde_json::from_value( + context + .parsed_fields + .get("attribute_domains") + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "CDFT frequency table parsing requires prior attribute_domains field" + ) + })?, + ) + .context("Failed to deserialize parsed CDFT attribute domains")?; + let num_objects = context.usize_field("num_objects").ok_or_else(|| { + anyhow::anyhow!("CDFT frequency table parsing requires prior num_objects field") + })?; + parse_cdft_frequency_tables(raw, &attribute_domains, num_objects) +} + +pub(super) fn parse_cdft_known_values_value( + raw: &str, + context: &CreateContext, +) -> Result> { + let attribute_domains: Vec = serde_json::from_value( + context + .parsed_fields + .get("attribute_domains") + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("CDFT known-value parsing requires prior attribute_domains field") + })?, + ) + .context("Failed to deserialize parsed CDFT attribute domains")?; + let num_objects = context.usize_field("num_objects").ok_or_else(|| { + anyhow::anyhow!("CDFT known-value parsing requires prior num_objects field") + })?; + parse_cdft_known_values(Some(raw), num_objects, &attribute_domains) +} + +pub(super) fn parse_cvp_bounds_value( + raw: Option<&str>, + context: &CreateContext, +) -> Result { + let basis_len = context + .parsed_fields + .get("basis") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .ok_or_else(|| anyhow::anyhow!("CVP bounds parsing requires a prior basis field"))?; + + let (lower, upper) = match raw { + Some(raw) => { + let parts: Vec = util::parse_comma_list(raw)?; + anyhow::ensure!( + parts.len() == 2, + "--bounds expects \"lower,upper\" (e.g., \"-10,10\")" + ); + (parts[0], parts[1]) + } + None => (-10, 10), + }; + let bounds = + vec![problemreductions::models::algebraic::VarBounds::bounded(lower, upper); basis_len]; + Ok(serde_json::to_value(bounds)?) +} + +pub(super) fn parse_biguint_list_value(raw: &str) -> Result { + let values: Vec = util::parse_biguint_list(raw)? + .into_iter() + .map(|value| value.to_string()) + .collect(); + Ok(serde_json::to_value(values)?) +} + +pub(super) fn parse_biguint_value(raw: &str) -> Result { + let value: BigUint = util::parse_decimal_biguint(raw)?; + Ok(serde_json::Value::String(value.to_string())) +} + +pub(super) fn parse_optional_bool_list_value(raw: &str) -> Result { + let values: Vec> = raw + .split(',') + .map(|entry| { + let entry = entry.trim(); + match entry { + "?" => Ok(None), + _ => Ok(Some(parse_bool_token(entry)?)), + } + }) + .collect::>()?; + Ok(serde_json::to_value(values)?) +} + +pub(super) fn parse_quantifiers_raw(raw: &str, context: &CreateContext) -> Result> { + let quantifiers: Vec = raw + .split(',') + .map(|entry| match entry.trim().to_lowercase().as_str() { + "e" | "exists" => Ok(Quantifier::Exists), + "a" | "forall" => Ok(Quantifier::ForAll), + other => Err(anyhow::anyhow!( + "Invalid quantifier '{}': expected E/Exists or A/ForAll", + other + )), + }) + .collect::>()?; + + if let Some(num_vars) = context.usize_field("num_vars") { + anyhow::ensure!( + quantifiers.len() == num_vars, + "Expected {num_vars} quantifiers but got {}", + quantifiers.len() + ); + } + + Ok(quantifiers) +} + +pub(super) fn parse_json_passthrough_value(raw: &str) -> Result { + serde_json::from_str(raw).context("Invalid JSON input") +} + +pub(super) fn parse_bool_token(raw: &str) -> Result { + match raw.trim() { + "1" | "true" | "TRUE" | "True" => Ok(true), + "0" | "false" | "FALSE" | "False" => Ok(false), + other => bail!("Invalid boolean entry '{other}': expected 0/1 or true/false"), + } +} + +pub(super) fn parse_simple_graph_value( + raw: &str, + context: &CreateContext, +) -> Result { + let raw = raw.trim(); + let num_vertices = context.usize_field("num_vertices").or(context.num_vertices); + let graph = if raw.is_empty() { + let num_vertices = num_vertices.ok_or_else(|| { + anyhow::anyhow!( + "Empty graph string. To create a graph with isolated vertices, provide num_vertices first." + ) + })?; + SimpleGraph::empty(num_vertices) + } else { + let edges = util::parse_edge_pairs(raw)?; + let inferred_num_vertices = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|max_vertex| max_vertex + 1) + .unwrap_or(0); + let num_vertices = match num_vertices { + Some(explicit) => { + anyhow::ensure!( + explicit >= inferred_num_vertices, + "num_vertices ({explicit}) is too small for the graph: need at least {inferred_num_vertices}" + ); + explicit + } + None => inferred_num_vertices, + }; + SimpleGraph::new(num_vertices, edges) + }; + Ok(serde_json::to_value(graph)?) +} + +pub(super) fn parse_directed_graph_value( + raw: &str, + context: &CreateContext, +) -> Result { + let (graph, _) = parse_directed_graph( + raw, + context.usize_field("num_vertices").or(context.num_vertices), + )?; + Ok(serde_json::to_value(graph)?) +} + +pub(super) fn parse_grid_subgraph_value(raw: &str, kings: bool) -> Result { + let positions = util::parse_positions::(raw, "0,0")?; + if kings { + Ok(serde_json::to_value(KingsSubgraph::new(positions))?) + } else { + Ok(serde_json::to_value(TriangularSubgraph::new(positions))?) + } +} + +pub(super) fn parse_unit_disk_graph_value( + raw: &str, + context: &CreateContext, +) -> Result { + let positions = util::parse_positions::(raw, "0.0,0.0")?; + let radius = context + .f64_field("radius") + .ok_or_else(|| anyhow::anyhow!("UnitDiskGraph parsing requires a prior radius field"))?; + Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) +} + +pub(super) fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { + match type_name { + "SimpleGraph" => "edge list: 0-1,1-2,2-3", + "G" => match graph_type { + Some("KingsSubgraph" | "TriangularSubgraph") => "integer positions: \"0,0;1,0;1,1\"", + Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"", + _ => "edge list: 0-1,1-2,2-3", + }, + "Vec<(Vec, Vec)>" => "semicolon-separated dependencies: \"0,1>2;0,2>3\"", + "Vec" => "comma-separated integers: 4,5,3,2,6", + "Vec" => "comma-separated: 1,2,3", + "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", + "Vec" => "comma-separated indices: 0,2,4", + "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { + "comma-separated weighted edges: 0-2:3,1-3:5" + } + "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", + "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", + "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", + "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", + "usize" => "integer", + "u64" => "integer", + "i64" => "integer", + "BigUint" => "nonnegative decimal integer", + "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", + "Vec" => "comma-separated integers: 3,7,1,8", + "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", + _ => "value", + } +} + +pub(super) fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { + match canonical { + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" + | "MinimumDominatingSet" => match graph_type { + Some("KingsSubgraph") => "--positions \"0,0;1,0;1,1;0,1\"", + Some("TriangularSubgraph") => "--positions \"0,0;0,1;1,0;1,1\"", + Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5", + _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", + }, + "KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3", + "VertexCover" => "--graph 0-1,1-2,0-2,2-3 --k 2", + "GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5", + "IntegralFlowBundles" => { + "--arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4" + } + "IntegralFlowWithMultipliers" => { + "--arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2" + } + "MinimumCutIntoBoundedSets" => { + "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --source 0 --sink 3 --size-bound 3" + } + "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 --max-weight 6" + } + "HamiltonianPath" => "--graph 0-1,1-2,2-3", + "HamiltonianPathBetweenTwoVertices" => { + "--graph 0-1,0-3,1-2,1-4,2-5,3-4,4-5,2-3 --source-vertex 0 --target-vertex 5" + } + "GraphPartitioning" => "--graph 0-1,1-2,2-3,3-0 --num-partitions 2", + "LongestPath" => { + "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" + } + "UndirectedFlowLowerBounds" => { + "--graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3" + } + "UndirectedTwoCommodityIntegralFlow" => { + "--graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1" + }, + "DisjointConnectingPaths" => { + "--graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5" + } + "IntegralFlowHomologousArcs" => { + "--arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\"" + } + "LengthBoundedDisjointPaths" => { + "--graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 4" + } + "PathConstrainedNetworkFlow" => { + "--arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3" + } + "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", + "BoundedDiameterSpanningTree" => { + "--graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --edge-weights 1,2,1,1,2,1,1 --weight-bound 5 --diameter-bound 3" + } + "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", + "LongestCircuit" => { + "--graph 0-1,1-2,2-3,3-4,4-5,5-0,0-3,1-4,2-5,3-5 --edge-weights 3,2,4,1,5,2,3,2,1,2" + } + "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" + } + "ShortestWeightConstrainedPath" => { + "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8" + } + "SteinerTreeInGraphs" => "--graph 0-1,1-2,2-3 --edge-weights 1,1,1 --terminals 0,3", + "BiconnectivityAugmentation" => { + "--graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5" + } + "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" + } + "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", + "NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"", + "QuantifiedBooleanFormulas" => { + "--num-vars 3 --clauses \"1,2;-1,3\" --quantifiers \"E,A,E\"" + } + "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", + "Maximum2Satisfiability" => "--num-vars 4 --clauses \"1,2;1,-2;-1,3;-1,-3;2,4;-3,-4;3,4\"", + "NonTautology" => { + "--num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\"" + } + "OneInThreeSatisfiability" => { + "--num-vars 4 --clauses \"1,2,3;-1,3,4;2,-3,-4\"" + } + "Planar3Satisfiability" => { + "--num-vars 4 --clauses \"1,2,3;-1,2,4;1,-3,4;-2,3,-4\"" + } + "QUBO" => "--matrix \"1,0.5;0.5,2\"", + "QuadraticAssignment" => "--matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"", + "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", + "KColoring" => "--graph 0-1,1-2,2-0 --k 3", + "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", + "MaximumLeafSpanningTree" => "--graph 0-1,0-2,0-3,1-4,2-4,2-5,3-5,4-5,1-3", + "EnsembleComputation" => "--universe-size 4 --subsets \"0,1,2;0,1,3\"", + "RootedTreeStorageAssignment" => { + "--universe-size 5 --subsets \"0,2;1,3;0,4;2,4\" --bound 1" + } + "MinMaxMulticenter" => { + "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" + } + "MinimumSumMulticenter" => { + "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2" + } + "BalancedCompleteBipartiteSubgraph" => { + "--left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3" + } + "MaximumAchromaticNumber" => "--graph 0-1,1-2,2-3,3-4,4-5,5-0", + "MaximumDomaticNumber" => "--graph 0-1,1-2,0-2", + "MinimumCoveringByCliques" => "--graph 0-1,1-2,0-2,2-3", + "MinimumIntersectionGraphBasis" => "--graph 0-1,1-2", + "MinimumMaximalMatching" => "--graph 0-1,1-2,2-3,3-4,4-5", + "DegreeConstrainedSpanningTree" => "--graph 0-1,0-2,0-3,1-2,1-4,2-3,3-4 --k 2", + "MonochromaticTriangle" => "--graph 0-1,0-2,0-3,1-2,1-3,2-3", + "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", + "PartitionIntoCliques" => "--graph 0-1,0-2,1-2,3-4,3-5,4-5 --k 3", + "PartitionIntoForests" => "--graph 0-1,1-2,2-0,3-4,4-5,5-3 --k 2", + "PartitionIntoPerfectMatchings" => "--graph 0-1,2-3,0-2,1-3 --k 2", + "Factoring" => "--target 15 --m 4 --n 4", + "CapacityAssignment" => { + "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --delay-budget 12" + } + "ProductionPlanning" => { + "--num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80" + } + "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", + "PreemptiveScheduling" => { + "--lengths 2,1,3,2,1 --num-processors 2 --precedences \"0>2,1>3\"" + } + "SchedulingToMinimizeWeightedCompletionTime" => { + "--lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2" + } + "JobShopScheduling" => { + "--jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1\" --num-processors 2" + } + "MinimumMultiwayCut" => "--graph 0-1,1-2,2-3 --terminals 0,2 --edge-weights 1,1,1", + "ExpectedRetrievalCost" => EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS, + "SequencingWithinIntervals" => "--release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1", + "StaffScheduling" => { + "--schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5" + } + "TimetableDesign" => { + "--num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\"" + } + "SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4", + "MultipleCopyFileAllocation" => { + MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS + } + "AcyclicPartition" => { + "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-weights 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" + } + "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3", + "RootedTreeArrangement" => "--graph 0-1,0-2,1-2,2-3,3-4 --bound 7", + "DirectedTwoCommodityIntegralFlow" => { + "--arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" --capacities 1,1,1,1,1,1,1,1 --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 --requirement-1 1 --requirement-2 1" + } + "MinimumEdgeCostFlow" => { + "--arcs \"0>1,0>2,0>3,1>4,2>4,3>4\" --edge-weights 3,1,2,0,0,0 --capacities 2,2,2,2,2,2 --source 0 --sink 4 --requirement 3" + } + "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", + "DirectedHamiltonianPath" => { + "--arcs \"0>1,0>3,1>3,1>4,2>0,2>4,3>2,3>5,4>5,5>1\" --num-vertices 6" + } + "Kernel" => "--arcs \"0>1,0>2,1>3,2>3,3>4,4>0,4>1\"", + "MinimumGeometricConnectedDominatingSet" => { + "--positions \"0,0;3,0;6,0;9,0;0,3;3,3;6,3;9,3\" --radius 3.5" + } + "MinimumDummyActivitiesPert" => "--arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6", + "FeasibleRegisterAssignment" => { + "--arcs \"0>1,0>2,1>3\" --assignment 0,1,0,0 --k 2 --num-vertices 4" + } + "MinimumFaultDetectionTestSet" => { + "--arcs \"0>2,0>3,1>3,1>4,2>5,3>5,3>6,4>6\" --inputs 0,1 --outputs 5,6 --num-vertices 7" + } + "MinimumWeightAndOrGraph" => { + "--arcs \"0>1,0>2,1>3,1>4,2>5,2>6\" --source 0 --gate-types \"AND,OR,OR,L,L,L,L\" --weights 1,2,3,1,4,2 --num-vertices 7" + } + "MinimumRegisterSufficiencyForLoops" => { + "--loop-length 6 --loop-variables \"0,3;2,3;4,3\"" + } + "RegisterSufficiency" => { + "--arcs \"2>0,2>1,3>1,4>2,4>3,5>0,6>4,6>5\" --bound 3 --num-vertices 7" + } + "StrongConnectivityAugmentation" => { + "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" + } + "MixedChinesePostman" => { + "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4" + } + "RuralPostman" => { + "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2" + } + "StackerCrane" => { + "--arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6" + } + "MultipleChoiceBranching" => { + "--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\" --threshold 10" + } + "AdditionalKey" => "--num-attributes 6 --dependencies \"0,1:2,3;2,3:4,5;4,5:0,1\" --relation-attrs 0,1,2,3,4,5 --known-keys \"0,1;2,3;4,5\"", + "ConsistencyOfDatabaseFrequencyTables" => { + "--num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\"" + } + "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", + "RectilinearPictureCompression" => { + "--matrix \"1,1,0,0;1,1,0,0;0,0,1,1;0,0,1,1\" --bound 2" + } + "SequencingToMinimizeWeightedTardiness" => { + "--lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" + } + "IntegerKnapsack" => "--sizes 3,4,5,2,7 --values 4,5,7,3,9 --capacity 15", + "SubsetProduct" => "--sizes 2,3,5,7,6,10 --target 210", + "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", + "MinimumAxiomSet" => { + "--n 8 --true-sentences 0,1,2,3,4,5,6,7 --implications \"0>2;0>3;1>4;1>5;2,4>6;3,5>7;6,7>0;6,7>1\"" + } + "IntegerExpressionMembership" => { + "--expression '{\"Sum\":[{\"Sum\":[{\"Union\":[{\"Atom\":1},{\"Atom\":4}]},{\"Union\":[{\"Atom\":3},{\"Atom\":6}]}]},{\"Union\":[{\"Atom\":2},{\"Atom\":5}]}]}' --target 12" + } + "NonLivenessFreePetriNet" => { + "--n 4 --m 3 --arcs \"0>0,1>1,2>2\" --output-arcs \"0>1,1>2,2>3\" --initial-marking 1,0,0,0" + } + "Betweenness" => "--n 5 --sets \"0,1,2;2,3,4;0,2,4;1,3,4\"", + "CyclicOrdering" => "--n 5 --sets \"0,1,2;2,3,0;1,3,4\"", + "Numerical3DimensionalMatching" => "--w-sizes 4,5 --x-sizes 4,5 --y-sizes 5,7 --bound 15", + "ThreePartition" => "--sizes 4,5,6,4,6,5 --bound 15", + "DynamicStorageAllocation" => "--release-times 0,0,1,2,3 --deadlines 3,2,4,5,5 --sizes 2,3,1,3,2 --capacity 6", + "KthLargestMTuple" => "--sets \"2,5,8;3,6;1,4,7\" --k 14 --bound 12", + "AlgebraicEquationsOverGF2" => "--num-vars 3 --equations \"0,1:2;1,2:0:;0:1:2:\"", + "QuadraticCongruences" => "--coeff-a 4 --coeff-b 15 --coeff-c 10", + "QuadraticDiophantineEquations" => "--coeff-a 3 --coeff-b 5 --coeff-c 53", + "SimultaneousIncongruences" => "--pairs \"2,2;1,3;2,5;3,7\"", + "BoyceCoddNormalFormViolation" => { + "--n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" + } + "Clustering" => { + "--distance-matrix \"0,1,1,3;1,0,1,3;1,1,0,3;3,3,3,0\" --k 2 --diameter-bound 1" + } + "SumOfSquaresPartition" => "--sizes 5,3,8,2,7,1 --num-groups 3", + "ComparativeContainment" => { + "--universe-size 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6" + } + "SetBasis" => "--universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3", + "SetSplitting" => "--universe-size 6 --subsets \"0,1,2;2,3,4;0,4,5;1,3,5\"", + "LongestCommonSubsequence" => { + "--strings \"010110;100101;001011\" --alphabet-size 2" + } + "GroupingBySwapping" => "--string \"0,1,2,0,1,2\" --bound 5", + "MinimumExternalMacroDataCompression" | "MinimumInternalMacroDataCompression" => { + "--string \"0,1,0,1\" --pointer-cost 2 --alphabet-size 2" + } + "MinimumCardinalityKey" => { + "--num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\"" + } + "PrimeAttributeName" => { + "--universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3" + } + "TwoDimensionalConsecutiveSets" => { + "--alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"" + } + "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\"", + "ConsecutiveBlockMinimization" => "--matrix '[[true,false,true],[false,true,true]]' --bound-k 2", + "ConsecutiveOnesMatrixAugmentation" => { + "--matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" + } + "SparseMatrixCompression" => "--matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2", + "MaximumLikelihoodRanking" => "--matrix \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"", + "MinimumMatrixCover" => "--matrix \"0,3,1,0;3,0,0,2;1,0,0,4;0,2,4,0\"", + "MinimumMatrixDomination" => "--matrix \"0,1,0;1,0,1;0,1,0\"", + "MinimumWeightDecoding" => { + "--matrix '[[true,false,true,true],[false,true,true,false],[true,true,false,true]]' --rhs 'true,true,false'" + } + "MinimumWeightSolutionToLinearEquations" => { + "--matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'" + } + "ConjunctiveBooleanQuery" => { + "--domain-size 6 --relations \"2:0,3|1,3|2,4;3:0,1,5|1,2,5\" --conjuncts-spec \"0:v0,c3;0:v1,c3;1:v0,v1,c5\"" + } + "ConjunctiveQueryFoldability" => "(use --example ConjunctiveQueryFoldability)", + "EquilibriumPoint" => "(use --example EquilibriumPoint)", + "SequencingToMinimizeMaximumCumulativeCost" => { + "--costs 2,-1,3,-2,1,-3 --precedence-pairs \"0>2,1>2,1>3,2>4,3>5,4>5\"" + } + "StringToStringCorrection" => { + "--source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2" + } + "FeasibleBasisExtension" => { + "--matrix '[[1,0,1,2,-1,0],[0,1,0,1,1,2],[0,0,1,1,0,1]]' --rhs '7,5,3' --required-columns '0,1'" + } + "MinimumCodeGenerationParallelAssignments" => { + "--num-variables 4 --assignments \"0:1,2;1:0;2:3;3:1,2\"" + } + "MinimumDecisionTree" => { + "--test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3" + } + "MinimumDisjunctiveNormalForm" => { + "--num-vars 3 --truth-table 0,1,1,1,1,1,1,0" + } + "SquareTiling" => { + "--num-colors 3 --tiles \"0,1,2,0;0,0,2,1;2,1,0,0;2,0,0,1\" --grid-size 2" + } + _ => "", + } +} + +pub(super) fn uses_edge_weights_flag(canonical: &str) -> bool { + matches!( + canonical, + "BottleneckTravelingSalesman" + | "BoundedDiameterSpanningTree" + | "KthBestSpanningTree" + | "LongestCircuit" + | "MaxCut" + | "MaximumMatching" + | "MixedChinesePostman" + | "RuralPostman" + | "TravelingSalesman" + ) +} + +pub(super) fn uses_edge_weights_flag_for_edge_lengths(canonical: &str) -> bool { + matches!( + canonical, + "LongestCircuit" | "MinMaxMulticenter" | "MinimumSumMulticenter" + ) +} + +pub(super) fn help_flag_name(canonical: &str, field_name: &str) -> String { + // Problem-specific overrides first + match (canonical, field_name) { + ("BoundedComponentSpanningForest", "max_components") => return "k".to_string(), + ("BoundedComponentSpanningForest", "max_weight") => return "max-weight".to_string(), + ("BoyceCoddNormalFormViolation", "num_attributes") => return "n".to_string(), + ("BoyceCoddNormalFormViolation", "functional_deps") => return "sets".to_string(), + ("BoyceCoddNormalFormViolation", "target_subset") => return "target".to_string(), + ("CapacityAssignment", "cost") => return "cost-matrix".to_string(), + ("CapacityAssignment", "delay") => return "delay-matrix".to_string(), + ("FlowShopScheduling", "num_processors") + | ("JobShopScheduling", "num_processors") + | ("OpenShopScheduling", "num_machines") + | ("SchedulingWithIndividualDeadlines", "num_processors") => { + return "num-processors/--m".to_string(); + } + ("JobShopScheduling", "jobs") => return "jobs".to_string(), + ("LengthBoundedDisjointPaths", "max_length") => return "max-length".to_string(), + ("ConsecutiveBlockMinimization", "bound") => return "bound-k".to_string(), + ("GroupingBySwapping", "budget") => return "bound".to_string(), + ("RectilinearPictureCompression", "bound") => return "bound".to_string(), + ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), + ("PrimeAttributeName", "dependencies") => return "dependencies".to_string(), + ("PrimeAttributeName", "query_attribute") => return "query-attribute".to_string(), + ("ClosestVectorProblem", "target") => return "target-vec".to_string(), + ("ConjunctiveBooleanQuery", "conjuncts") => return "conjuncts-spec".to_string(), + ("MixedChinesePostman", "arc_weights") => return "arc-weights".to_string(), + ("ConsecutiveOnesMatrixAugmentation", "bound") => return "bound".to_string(), + ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), + ("SparseMatrixCompression", "bound_k") => return "bound-k".to_string(), + ("MinimumCodeGenerationParallelAssignments", "num_variables") => { + return "num-variables".to_string(); + } + ("MinimumCodeGenerationParallelAssignments", "assignments") => { + return "assignments".to_string(); + } + ("StackerCrane", "edges") => return "graph".to_string(), + ("StackerCrane", "arc_lengths") => return "arc-lengths".to_string(), + ("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(), + ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), + ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), + _ => {} + } + // Edge-weight problems use --edge-weights instead of --weights + if field_name == "weights" && uses_edge_weights_flag(canonical) { + return "edge-weights".to_string(); + } + if field_name == "edge_lengths" && uses_edge_weights_flag_for_edge_lengths(canonical) { + return "edge-weights".to_string(); + } + // General field-name overrides (previously in cli_flag_name) + match field_name { + "universe_size" => "universe-size".to_string(), + "collection" | "subsets" => "subsets".to_string(), + "left_size" => "left".to_string(), + "right_size" => "right".to_string(), + "edges" => "biedges".to_string(), + "vertex_weights" => "weights".to_string(), + "potential_weights" => "potential-weights".to_string(), + "num_tasks" => "num-tasks".to_string(), + "precedences" => "precedences".to_string(), + "threshold" => "threshold".to_string(), + "lengths" => "lengths".to_string(), + _ => field_name.replace('_', "-"), + } +} + +pub(super) fn reject_vertex_weights_for_edge_weight_problem( + args: &CreateArgs, + canonical: &str, + graph_type: Option<&str>, +) -> Result<()> { + if args.weights.is_some() && uses_edge_weights_flag(canonical) { + bail!( + "{canonical} uses --edge-weights, not --weights.\n\n\ + Usage: pred create {} {}", + match graph_type { + Some(g) => format!("{canonical}/{g}"), + None => canonical.to_string(), + }, + example_for(canonical, graph_type) + ); + } + Ok(()) +} + +pub(super) fn help_flag_hint( + canonical: &str, + field_name: &str, + type_name: &str, + graph_type: Option<&str>, +) -> &'static str { + match (canonical, field_name) { + ("BoundedComponentSpanningForest", "max_weight") => "integer", + ("SequencingWithinIntervals", "release_times") => "comma-separated integers: 0,0,5", + ("DynamicStorageAllocation", "release_times") => "comma-separated arrival times: 0,0,1,2,3", + ("DynamicStorageAllocation", "deadlines") => "comma-separated departure times: 3,2,4,5,5", + ("DynamicStorageAllocation", "sizes") => "comma-separated item sizes: 2,3,1,3,2", + ("DynamicStorageAllocation", "capacity") => "memory size D: 6", + ("DisjointConnectingPaths", "terminal_pairs") => "comma-separated pairs: 0-3,2-5", + ("PrimeAttributeName", "dependencies") => { + "semicolon-separated dependencies: \"0,1>2,3;2,3>0,1\"" + } + ("LongestCommonSubsequence", "strings") => { + "raw strings: \"ABAC;BACA\" or symbol lists: \"0,1,0;1,0,1\"" + } + ("GroupingBySwapping", "string") => "symbol list: \"0,1,2,0,1,2\"", + ("MinimumExternalMacroDataCompression", "string") + | ("MinimumInternalMacroDataCompression", "string") => "symbol list: \"0,1,0,1\"", + ("MinimumExternalMacroDataCompression", "pointer_cost") + | ("MinimumInternalMacroDataCompression", "pointer_cost") => "positive integer: 2", + ("MinimumAxiomSet", "num_sentences") => "total number of sentences: 8", + ("MinimumAxiomSet", "true_sentences") => "comma-separated indices: 0,1,2,3,4,5,6,7", + ("MinimumAxiomSet", "implications") => "semicolon-separated rules: \"0>2;0>3;1>4;2,4>6\"", + ("ShortestCommonSupersequence", "strings") => "symbol lists: \"0,1,2;1,2,0\"", + ("MultipleChoiceBranching", "partition") => "semicolon-separated groups: \"0,1;2,3\"", + ("IntegralFlowHomologousArcs", "homologous_pairs") => { + "semicolon-separated arc-index equalities: \"2=5;4=3\"" + } + ("ConsistencyOfDatabaseFrequencyTables", "attribute_domains") => { + "comma-separated domain sizes: 2,3,2" + } + ("ConsistencyOfDatabaseFrequencyTables", "frequency_tables") => { + "semicolon-separated tables: \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\"" + } + ("ConsistencyOfDatabaseFrequencyTables", "known_values") => { + "semicolon-separated triples: \"0,0,0;3,0,1;1,2,1\"" + } + ("IntegralFlowBundles", "bundles") => "semicolon-separated groups: \"0,1;2,5;3,4\"", + ("IntegralFlowBundles", "bundle_capacities") => "comma-separated capacities: 1,1,1", + ("PathConstrainedNetworkFlow", "paths") => { + "semicolon-separated arc-index paths: \"0,2,5,8;1,4,7,9\"" + } + ("ConsecutiveBlockMinimization", "matrix") => { + "JSON 2D bool array: '[[true,false,true],[false,true,true]]'" + } + ("ConsecutiveOnesMatrixAugmentation", "matrix") => { + "semicolon-separated 0/1 rows: \"1,0;0,1\"" + } + ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("MaximumLikelihoodRanking", "matrix") => { + "semicolon-separated i32 rows: \"0,4,3,5;1,0,4,3;2,1,0,4;0,2,1,0\"" + } + ("MinimumMatrixCover", "matrix") => "semicolon-separated i64 rows: \"0,3,1;3,0,2;1,2,0\"", + ("MinimumMatrixDomination", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("MinimumWeightDecoding", "matrix") => "JSON 2D bool array: '[[true,false],[false,true]]'", + ("MinimumWeightDecoding", "target") => "comma-separated booleans: \"true,true,false\"", + ("MinimumWeightSolutionToLinearEquations", "matrix") => { + "JSON 2D integer array: '[[1,2,3],[4,5,6]]'" + } + ("MinimumWeightSolutionToLinearEquations", "rhs") => "comma-separated integers: \"5,4\"", + ("FeasibleBasisExtension", "matrix") => "JSON 2D integer array: '[[1,0,1],[0,1,0]]'", + ("FeasibleBasisExtension", "rhs") => "comma-separated integers: \"7,5,3\"", + ("FeasibleBasisExtension", "required_columns") => "comma-separated column indices: \"0,1\"", + ("MinimumCodeGenerationParallelAssignments", "assignments") => { + "semicolon-separated target:reads entries: \"0:1,2;1:0;2:3;3:1,2\"" + } + ("NonTautology", "disjuncts") => "semicolon-separated disjuncts: \"1,2,3;-1,-2,-3\"", + ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { + "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + } + ("TimetableDesign", "requirements") => "semicolon-separated rows: \"1,0,1;0,1,0\"", + _ => type_format_hint(type_name, graph_type), + } +} + +pub(super) fn parse_nonnegative_usize_bound( + bound: i64, + problem_name: &str, + usage: &str, +) -> Result { + usize::try_from(bound) + .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) +} + +pub(super) fn validate_prescribed_paths_against_graph( + graph: &DirectedGraph, + paths: &[Vec], + source: usize, + sink: usize, + usage: &str, +) -> Result<()> { + let arcs = graph.arcs(); + for path in paths { + anyhow::ensure!( + !path.is_empty(), + "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" + ); + let mut visited_vertices = BTreeSet::from([source]); + let mut current = source; + for &arc_index in path { + let &(tail, head) = arcs.get(arc_index).ok_or_else(|| { + anyhow::anyhow!( + "Path arc index {arc_index} out of bounds for {} arcs\n\n{usage}", + arcs.len() + ) + })?; + anyhow::ensure!( + tail == current, + "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}\n\n{usage}" + ); + anyhow::ensure!( + visited_vertices.insert(head), + "prescribed path repeats vertex {head}, so it is not a simple path\n\n{usage}" + ); + current = head; + } + anyhow::ensure!( + current == sink, + "prescribed path must end at sink {sink}, ended at {current}\n\n{usage}" + ); + } + Ok(()) +} + +pub(super) fn resolve_processor_count_flags( + problem_name: &str, + usage: &str, + num_processors: Option, + m_alias: Option, +) -> Result> { + match (num_processors, m_alias) { + (Some(num_processors), Some(m_alias)) => { + anyhow::ensure!( + num_processors == m_alias, + "{problem_name} received conflicting processor counts: --num-processors={num_processors} but --m={m_alias}\n\n{usage}" + ); + Ok(Some(num_processors)) + } + (Some(num_processors), None) => Ok(Some(num_processors)), + (None, Some(m_alias)) => Ok(Some(m_alias)), + (None, None) => Ok(None), + } +} + +pub(super) fn validate_sequencing_within_intervals_inputs( + release_times: &[u64], + deadlines: &[u64], + lengths: &[u64], + usage: &str, +) -> Result<()> { + if release_times.len() != deadlines.len() { + bail!("release_times and deadlines must have the same length\n\n{usage}"); + } + if release_times.len() != lengths.len() { + bail!("release_times and lengths must have the same length\n\n{usage}"); + } + + for (i, ((&release_time, &deadline), &length)) in release_times + .iter() + .zip(deadlines.iter()) + .zip(lengths.iter()) + .enumerate() + { + let end = release_time.checked_add(length).ok_or_else(|| { + anyhow::anyhow!("Task {i}: overflow computing r(i) + l(i)\n\n{usage}") + })?; + if end > deadline { + bail!( + "Task {i}: r({}) + l({}) > d({}), time window is empty\n\n{usage}", + release_time, + length, + deadline + ); + } + } + + Ok(()) +} + +pub(super) fn print_problem_help( + canonical: &str, + resolved_variant: &BTreeMap, +) -> Result<()> { + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .filter(|graph_type| *graph_type != "SimpleGraph"); + let is_geometry = matches!( + graph_type, + Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") + ); + let schemas = collect_schemas(); + let schema = schemas.iter().find(|s| s.name == canonical); + + if let Some(s) = schema { + eprintln!("{}\n {}\n", canonical, s.description); + eprintln!("Parameters:"); + for field in &s.fields { + let flag_name = + problem_help_flag_name(canonical, &field.name, &field.type_name, is_geometry); + // For geometry variants, show --positions instead of --graph + if field.type_name == "G" && is_geometry { + let hint = type_format_hint(&field.type_name, graph_type); + eprintln!(" --{:<16} {} ({hint})", flag_name, field.description); + if graph_type == Some("UnitDiskGraph") { + eprintln!(" --{:<16} Distance threshold [default: 1.0]", "radius"); + } + } else if field.type_name == "DirectedGraph" { + // DirectedGraph fields use --arcs, not --graph + let hint = type_format_hint(&field.type_name, graph_type); + eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); + } else if field.type_name == "MixedGraph" { + eprintln!( + " --{:<16} Undirected edges E of the mixed graph (edge list: 0-1,1-2,2-3)", + "graph" + ); + eprintln!( + " --{:<16} Directed arcs A of the mixed graph (directed arcs: 0>1,1>2,2>0)", + "arcs" + ); + } else if field.type_name == "BipartiteGraph" { + eprintln!( + " --{:<16} Vertices in the left partition (integer)", + "left" + ); + eprintln!( + " --{:<16} Vertices in the right partition (integer)", + "right" + ); + eprintln!( + " --{:<16} Bipartite edges as left-right pairs (edge list: 0-0,0-1,1-2)", + "biedges" + ); + } else { + let hint = help_flag_hint(canonical, &field.name, &field.type_name, graph_type); + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); + } + } + if canonical == "GraphPartitioning" { + eprintln!( + " --{:<16} Number of partitions in the balanced partitioning model (must be 2) (integer)", + "num-partitions" + ); + } + } else { + bail!("{}", crate::problem_name::unknown_problem_error(canonical)); + } + + let example = schema_help_example_for(canonical, resolved_variant).or_else(|| { + let fallback = example_for(canonical, graph_type); + (!fallback.is_empty()).then(|| fallback.to_string()) + }); + if let Some(example) = example { + eprintln!("\nExample:"); + eprintln!( + " pred create {} {}", + match graph_type { + Some(g) => format!("{canonical}/{g}"), + None => canonical.to_string(), + }, + example + ); + } + Ok(()) +} + +pub(super) fn schema_help_example_for( + canonical: &str, + resolved_variant: &BTreeMap, +) -> Option { + let schema = collect_schemas() + .into_iter() + .find(|schema| schema.name == canonical)?; + let example = problemreductions::example_db::find_model_example(&ProblemRef { + name: canonical.to_string(), + variant: resolved_variant.clone(), + }) + .ok()?; + let instance = example.instance.as_object()?; + let graph_type = resolved_variant + .get("graph") + .map(String::as_str) + .filter(|graph_type| *graph_type != "SimpleGraph"); + let is_geometry = matches!( + graph_type, + Some("KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph") + ); + + let mut args = Vec::new(); + for field in &schema.fields { + let value = instance.get(&field.name)?; + let concrete_type = resolve_schema_field_type(&field.type_name, resolved_variant); + let flag_name = + schema_example_flag_name(canonical, &field.name, &field.type_name, is_geometry); + let rendered = + format_schema_help_example_value(canonical, &field.name, &concrete_type, value)?; + args.push(format!("--{flag_name} {}", quote_cli_arg(&rendered))); + } + Some(args.join(" ")) +} + +pub(super) fn schema_example_flag_name( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> String { + problem_help_flag_name(canonical, field_name, field_type, is_geometry) + .split('/') + .next() + .unwrap_or(field_name) + .trim_start_matches("--") + .to_string() +} + +pub(super) fn quote_cli_arg(raw: &str) -> String { + if raw.is_empty() + || raw.chars().any(|ch| { + ch.is_whitespace() + || matches!( + ch, + ';' | '>' | '|' | '[' | ']' | '{' | '}' | '(' | ')' | '"' | '\'' + ) + }) + { + format!("\"{}\"", raw.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + raw.to_string() + } +} + +pub(super) fn format_schema_help_example_value( + canonical: &str, + field_name: &str, + concrete_type: &str, + value: &serde_json::Value, +) -> Option { + match (canonical, field_name) { + ("ConsecutiveBlockMinimization", "matrix") + | ("FeasibleBasisExtension", "matrix") + | ("MinimumWeightDecoding", "matrix") + | ("MinimumWeightSolutionToLinearEquations", "matrix") => { + return serde_json::to_string(value).ok(); + } + _ => {} + } + match normalize_type_name(concrete_type).as_str() { + "SimpleGraph" => format_simple_graph_example(value), + "DirectedGraph" => format_directed_graph_example(value), + "Vec" => format_cnf_clause_list_example(value), + "Vec" => format_quantifier_list_example(value), + "Vec>" => format_job_shop_example(value), + "Vec<(Vec,Vec)>" => format_dependency_example(value), + "Vec" | "Vec" | "Vec" | "Vec" | "Vec" | "Vec" => { + format_scalar_array_example(value) + } + "Vec" => format_bool_array_example(value), + "Vec>" | "Vec>" | "Vec>" | "Vec>" + | "Vec>" => format_nested_numeric_rows(value), + "Vec>" => format_bool_matrix_example(value), + "Vec" => Some( + value + .as_array()? + .iter() + .map(|entry| entry.as_str().map(str::to_string)) + .collect::>>()? + .join(";"), + ), + "usize" | "u64" | "i32" | "i64" | "f64" | "BigUint" => format_scalar_example(value), + _ => None, + } +} + +pub(super) fn format_scalar_example(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::String(string) => Some(string.clone()), + serde_json::Value::Bool(boolean) => Some(boolean.to_string()), + _ => None, + } +} + +pub(super) fn format_scalar_array_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(format_scalar_example) + .collect::>>()? + .join(","), + ) +} + +pub(super) fn format_bool_array_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|entry| { + entry + .as_bool() + .map(|boolean| if boolean { "1" } else { "0" }.to_string()) + }) + .collect::>>()? + .join(","), + ) +} + +pub(super) fn format_nested_numeric_rows(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(format_scalar_array_example) + .collect::>>()? + .join(";"), + ) +} + +pub(super) fn format_cnf_clause_list_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|clause| format_scalar_array_example(clause.get("literals")?)) + .collect::>>()? + .join(";"), + ) +} + +pub(super) fn format_bool_matrix_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(format_bool_array_example) + .collect::>>()? + .join(";"), + ) +} + +pub(super) fn format_simple_graph_example(value: &serde_json::Value) -> Option { + Some( + value + .get("edges")? + .as_array()? + .iter() + .map(|edge| { + let pair = edge.as_array()?; + Some(format!( + "{}-{}", + pair.first()?.as_u64()?, + pair.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) +} + +pub(super) fn format_directed_graph_example(value: &serde_json::Value) -> Option { + Some( + value + .get("arcs")? + .as_array()? + .iter() + .map(|arc| { + let pair = arc.as_array()?; + Some(format!( + "{}>{}", + pair.first()?.as_u64()?, + pair.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) +} + +pub(super) fn format_quantifier_list_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|entry| match entry.as_str()? { + "Exists" => Some("E".to_string()), + "ForAll" => Some("A".to_string()), + _ => None, + }) + .collect::>>()? + .join(","), + ) +} + +pub(super) fn format_job_shop_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|job| { + Some( + job.as_array()? + .iter() + .map(|task| { + let task = task.as_array()?; + Some(format!( + "{}:{}", + task.first()?.as_u64()?, + task.get(1)?.as_u64()? + )) + }) + .collect::>>()? + .join(","), + ) + }) + .collect::>>()? + .join(";"), + ) +} + +pub(super) fn format_dependency_example(value: &serde_json::Value) -> Option { + Some( + value + .as_array()? + .iter() + .map(|dependency| { + let dependency = dependency.as_array()?; + let lhs = format_scalar_array_example(dependency.first()?)?; + let rhs = format_scalar_array_example(dependency.get(1)?)?; + Some(format!("{lhs}>{rhs}")) + }) + .collect::>>()? + .join(";"), + ) +} + +pub(super) fn problem_help_flag_name( + canonical: &str, + field_name: &str, + field_type: &str, + is_geometry: bool, +) -> String { + if field_type == "G" && is_geometry { + return "positions".to_string(); + } + if field_type == "DirectedGraph" { + return "arcs".to_string(); + } + if field_type == "MixedGraph" { + return "graph".to_string(); + } + if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { + return "max-length".to_string(); + } + if canonical == "GeneralizedHex" && field_name == "target" { + return "sink".to_string(); + } + if canonical == "StringToStringCorrection" { + return match field_name { + "source" => "source-string".to_string(), + "target" => "target-string".to_string(), + "bound" => "bound".to_string(), + _ => help_flag_name(canonical, field_name), + }; + } + help_flag_name(canonical, field_name) +} + +pub(super) fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { + match usage { + Some(usage) => anyhow::anyhow!("{message}\n\n{usage}"), + None => anyhow::anyhow!("{message}"), + } +} + +pub(super) fn validate_length_bounded_disjoint_paths_args( + num_vertices: usize, + source: usize, + sink: usize, + bound: i64, + usage: Option<&str>, +) -> Result { + let max_length = usize::try_from(bound).map_err(|_| { + lbdp_validation_error( + "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths", + usage, + ) + })?; + if source >= num_vertices || sink >= num_vertices { + return Err(lbdp_validation_error( + "--source and --sink must be valid graph vertices", + usage, + )); + } + if source == sink { + return Err(lbdp_validation_error( + "--source and --sink must be distinct", + usage, + )); + } + if max_length == 0 { + return Err(lbdp_validation_error( + "--max-length must be positive", + usage, + )); + } + Ok(max_length) +} diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs new file mode 100644 index 00000000..04ed464f --- /dev/null +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -0,0 +1,2698 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::Parser; + +use super::ensure_attribute_indices_in_range; +use super::parse_bool_rows; +use super::schema_support::*; +use super::*; +use crate::cli::{Cli, Commands}; +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() { + assert_eq!( + problem_help_flag_name("LengthBoundedDisjointPaths", "max_length", "usize", false), + "max-length" + ); +} + +#[test] +fn test_problem_help_preserves_generic_field_kebab_case() { + assert_eq!( + problem_help_flag_name("LengthBoundedDisjointPaths", "max_paths", "usize", false,), + "max-paths" + ); +} + +#[test] +fn test_help_flag_name_mentions_m_alias_for_scheduling_processors() { + assert_eq!( + help_flag_name("SchedulingWithIndividualDeadlines", "num_processors"), + "num-processors/--m" + ); + assert_eq!( + help_flag_name("FlowShopScheduling", "num_processors"), + "num-processors/--m" + ); +} + +#[test] +fn test_parse_field_value_parses_simple_graph_to_json() { + let value = parse_field_value("SimpleGraph", "graph", "0-1,1-2", &CreateContext::default()) + .expect("parse graph"); + + assert_eq!( + value, + serde_json::json!({ + "num_vertices": 3, + "edges": [[0, 1], [1, 2]], + }) + ); +} + +#[test] +fn test_parse_field_value_parses_dependency_pairs() { + let value = parse_field_value( + "Vec<(Vec, Vec)>", + "dependencies", + "0,1>2,3;2>4", + &CreateContext::default(), + ) + .expect("parse dependencies"); + + assert_eq!(value, serde_json::json!([[[0, 1], [2, 3]], [[2], [4]],])); +} + +#[test] +fn test_parse_field_value_parses_job_shop_jobs() { + let value = parse_field_value( + "Vec>", + "jobs", + "0:3,1:4;1:2,0:3,1:2", + &CreateContext::default(), + ) + .expect("parse jobs"); + + assert_eq!( + value, + serde_json::json!([[[0, 3], [1, 4]], [[1, 2], [0, 3], [1, 2]],]) + ); +} + +#[test] +fn test_parse_field_value_parses_quantifiers_using_context_num_vars() { + let context = CreateContext::default().with_field("num_vars", serde_json::json!(3)); + let value = parse_field_value("Vec", "quantifiers", "E,A,E", &context) + .expect("parse quantifiers"); + + assert_eq!(value, serde_json::json!(["Exists", "ForAll", "Exists"])); +} + +#[test] +fn test_schema_driven_supported_problem_includes_cli_creatable_problem() { + assert!( + schema_driven_supported_problem("ConjunctiveBooleanQuery"), + "all CLI-creatable problems should opt into schema-driven create unless explicitly excluded" + ); + assert!(!schema_driven_supported_problem("ILP")); + assert!(!schema_driven_supported_problem("CircuitSAT")); +} + +#[test] +fn test_create_schema_driven_builds_job_shop_scheduling() { + let cli = Cli::parse_from([ + "pred", + "create", + "JobShopScheduling", + "--jobs", + "0:3,1:4;1:2,0:3,1:2", + "--num-processors", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven(&args, "JobShopScheduling", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support JobShopScheduling"); + + let entry = problemreductions::registry::find_variant_entry("JobShopScheduling", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_processors"], 2); + assert_eq!(data["jobs"][0], serde_json::json!([[0, 3], [1, 4]])); +} + +#[test] +fn test_create_schema_driven_builds_quantified_boolean_formulas() { + let cli = Cli::parse_from([ + "pred", + "create", + "QuantifiedBooleanFormulas", + "--num-vars", + "3", + "--quantifiers", + "E,A,E", + "--clauses", + "1,2;-1,3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "QuantifiedBooleanFormulas", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support QBF"); + + let entry = + problemreductions::registry::find_variant_entry("QuantifiedBooleanFormulas", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!( + data["quantifiers"], + serde_json::json!(["Exists", "ForAll", "Exists"]) + ); +} + +#[test] +fn test_create_schema_driven_builds_undirected_flow_lower_bounds() { + let cli = Cli::parse_from([ + "pred", + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3", + "--capacities", + "2,2,2,2", + "--lower-bounds", + "1,0,0,1", + "--source", + "0", + "--sink", + "3", + "--requirement", + "2", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "UndirectedFlowLowerBounds", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support UndirectedFlowLowerBounds"); + + let entry = + problemreductions::registry::find_variant_entry("UndirectedFlowLowerBounds", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["num_vertices"], 4); + assert_eq!(data["capacities"], serde_json::json!([2, 2, 2, 2])); + assert_eq!(data["lower_bounds"], serde_json::json!([1, 0, 0, 1])); +} + +#[test] +fn test_create_schema_driven_builds_conjunctive_boolean_query() { + let cli = Cli::parse_from([ + "pred", + "create", + "ConjunctiveBooleanQuery", + "--domain-size", + "6", + "--relations", + "2:0,3|1,3;3:0,1,5|1,2,5", + "--conjuncts-spec", + "0:v0,c3;0:v1,c3;1:v0,v1,c5", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven(&args, "ConjunctiveBooleanQuery", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support CBQ"); + + let entry = + problemreductions::registry::find_variant_entry("ConjunctiveBooleanQuery", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_variables"], 2); + assert_eq!(data["relations"][0]["arity"], 2); + assert_eq!( + data["conjuncts"][1], + serde_json::json!([0, [{"Variable": 1}, {"Constant": 3}]]) + ); +} + +#[test] +fn test_create_schema_driven_builds_closest_vector_problem_with_default_bounds() { + let cli = Cli::parse_from([ + "pred", + "create", + "CVP", + "--basis", + "1,0;0,1", + "--target-vec", + "0.5,0.5", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let resolved_variant = variant_map(&[("weight", "i32")]); + let (data, variant) = create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support CVP"); + + let entry = problemreductions::registry::find_variant_entry("ClosestVectorProblem", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["basis"], serde_json::json!([[1, 0], [0, 1]])); + assert_eq!( + data["bounds"], + serde_json::json!([ + {"lower": -10, "upper": 10}, + {"lower": -10, "upper": 10}, + ]) + ); +} + +#[test] +fn test_create_schema_driven_builds_cdft() { + let cli = Cli::parse_from([ + "pred", + "create", + "ConsistencyOfDatabaseFrequencyTables", + "--num-objects", + "6", + "--attribute-domains", + "2,3,2", + "--frequency-tables", + "0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1", + "--known-values", + "0,0,0;3,0,1;1,2,1", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven( + &args, + "ConsistencyOfDatabaseFrequencyTables", + &BTreeMap::new(), + ) + .expect("schema-driven create should parse") + .expect("schema-driven path should support CDFT"); + + let entry = problemreductions::registry::find_variant_entry( + "ConsistencyOfDatabaseFrequencyTables", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["num_objects"], 6); + assert_eq!(data["frequency_tables"][0]["attribute_a"], 0); + assert_eq!(data["known_values"][2]["attribute"], 2); +} + +#[test] +fn test_create_schema_driven_builds_balanced_complete_bipartite_subgraph() { + let cli = Cli::parse_from([ + "pred", + "create", + "BalancedCompleteBipartiteSubgraph", + "--left", + "4", + "--right", + "4", + "--biedges", + "0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3", + "--k", + "3", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "BalancedCompleteBipartiteSubgraph", &BTreeMap::new()) + .expect("schema-driven create should parse") + .expect("schema-driven path should support balanced biclique"); + + let entry = problemreductions::registry::find_variant_entry( + "BalancedCompleteBipartiteSubgraph", + &variant, + ) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["left_size"], 4); + assert_eq!(data["graph"]["right_size"], 4); + assert_eq!(data["k"], 3); +} + +#[test] +fn test_create_schema_driven_builds_mixed_chinese_postman() { + let cli = Cli::parse_from([ + "pred", + "create", + "MixedChinesePostman/i32", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3,1,2", + "--arc-weights", + "2,3,1,4", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let resolved_variant = variant_map(&[("weight", "i32")]); + let (data, variant) = create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support mixed chinese postman"); + + let entry = problemreductions::registry::find_variant_entry("MixedChinesePostman", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["num_vertices"], 5); + assert_eq!(data["arc_weights"], serde_json::json!([2, 3, 1, 4])); + assert_eq!(data["edge_weights"], serde_json::json!([2, 3, 1, 2])); +} + +#[test] +fn test_create_schema_driven_builds_unit_disk_graph_problem_with_default_radius() { + let cli = Cli::parse_from([ + "pred", + "create", + "MIS/UnitDiskGraph", + "--positions", + "0,0;1,0;0.5,0.8", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let resolved_variant = variant_map(&[("graph", "UnitDiskGraph"), ("weight", "One")]); + let (data, variant) = create_schema_driven(&args, "MaximumIndependentSet", &resolved_variant) + .expect("schema-driven create should parse") + .expect("schema-driven path should support UnitDiskGraph variants"); + + let entry = problemreductions::registry::find_variant_entry("MaximumIndependentSet", &variant) + .expect("variant entry"); + (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); + assert_eq!(data["graph"]["positions"].as_array().unwrap().len(), 3); + assert_eq!( + data["graph"]["edges"], + serde_json::json!([[0, 1], [0, 2], [1, 2]]) + ); +} + +#[test] +fn test_schema_help_example_for_qbf_uses_example_db() { + let example = schema_help_example_for("QuantifiedBooleanFormulas", &BTreeMap::new()).unwrap(); + assert_eq!( + example, + "--num-vars 2 --quantifiers E,A --clauses \"1,2;1,-2\"" + ); +} + +#[test] +fn test_schema_help_example_for_cbm_uses_json_matrix_syntax() { + let example = + schema_help_example_for("ConsecutiveBlockMinimization", &BTreeMap::new()).unwrap(); + assert!(example.contains("--matrix \"[[false,true,false,false,false,false],[true,false,true,false,false,false],[false,true,false,true,false,false],[false,false,true,false,true,false],[false,false,false,true,false,true],[false,false,false,false,true,false]]\"")); + assert!(example.contains("--bound-k 6")); +} + +#[test] +fn test_problem_help_flag_name_uses_bound_for_grouping_by_swapping_budget() { + assert_eq!( + problem_help_flag_name("GroupingBySwapping", "budget", "usize", false), + "bound" + ); +} + +#[test] +fn test_problem_help_flag_name_preserves_edge_lengths_for_shortest_weight_constrained_path() { + assert_eq!( + problem_help_flag_name( + "ShortestWeightConstrainedPath", + "edge_lengths", + "Vec", + false + ), + "edge-lengths" + ); +} + +#[test] +fn test_problem_help_flag_name_uses_edge_weights_for_longest_circuit_edge_lengths() { + assert_eq!( + problem_help_flag_name("LongestCircuit", "edge_lengths", "Vec", false), + "edge-weights" + ); +} + +#[test] +fn test_ensure_attribute_indices_in_range_rejects_out_of_range_index() { + let err = ensure_attribute_indices_in_range(&[0, 4], 3, "Functional dependency '0:4' rhs") + .unwrap_err(); + assert!( + err.to_string().contains("out of range"), + "unexpected error: {err}" + ); +} + +#[test] +fn test_create_scheduling_with_individual_deadlines_accepts_m_alias() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "SchedulingWithIndividualDeadlines", + "--num-tasks", + "3", + "--deadlines", + "1,1,2", + "--m", + "2", + ]) + .expect("parse create command"); + + let Commands::Create(args) = cli.command else { + panic!("expected create subcommand"); + }; + + let out = OutputConfig { + output: Some( + std::env::temp_dir() + .join("pred_test_create_scheduling_with_individual_deadlines_m_alias.json"), + ), + quiet: true, + json: false, + auto_json: false, + }; + create(&args, &out).expect("`--m` should satisfy --num-processors alias"); + + let created: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(out.output.as_ref().unwrap()).unwrap()) + .unwrap(); + std::fs::remove_file(out.output.as_ref().unwrap()).ok(); + + assert_eq!(created["type"], "SchedulingWithIndividualDeadlines"); + assert_eq!(created["data"]["num_processors"], 2); +} + +#[test] +fn test_create_prime_attribute_name_accepts_canonical_flags() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PrimeAttributeName", + "--universe", + "6", + "--dependencies", + "0,1>2,3,4,5;2,3>0,1,4,5", + "--query-attribute", + "3", + ]) + .expect("parse create command"); + + let Commands::Create(args) = cli.command else { + panic!("expected create subcommand"); + }; + + let output_path = temp_output_path("prime_attribute_name"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create PrimeAttributeName JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "PrimeAttributeName"); + assert_eq!(created["data"]["query_attribute"], 3); + assert_eq!( + created["data"]["dependencies"][0], + serde_json::json!([[0, 1], [2, 3, 4, 5]]) + ); +} + +#[test] +fn test_problem_help_uses_prime_attribute_name_cli_overrides() { + assert_eq!( + problem_help_flag_name("PrimeAttributeName", "num_attributes", "usize", false), + "universe" + ); + assert_eq!( + problem_help_flag_name( + "PrimeAttributeName", + "dependencies", + "Vec<(Vec, Vec)>", + false, + ), + "dependencies" + ); + assert_eq!( + problem_help_flag_name("PrimeAttributeName", "query_attribute", "usize", false), + "query-attribute" + ); +} + +#[test] +fn test_problem_help_uses_problem_specific_lcs_strings_hint() { + assert_eq!( + help_flag_hint( + "LongestCommonSubsequence", + "strings", + "Vec>", + None, + ), + "raw strings: \"ABAC;BACA\" or symbol lists: \"0,1,0;1,0,1\"" + ); +} + +#[test] +fn test_problem_help_uses_string_to_string_correction_cli_flags() { + assert_eq!( + problem_help_flag_name("StringToStringCorrection", "source", "Vec", false), + "source-string" + ); + assert_eq!( + problem_help_flag_name("StringToStringCorrection", "target", "Vec", false), + "target-string" + ); + assert_eq!( + problem_help_flag_name("StringToStringCorrection", "bound", "usize", false), + "bound" + ); +} + +#[test] +fn test_problem_help_keeps_generic_vec_vec_usize_hint_for_other_models() { + assert_eq!( + help_flag_hint("SetBasis", "sets", "Vec>", None), + "semicolon-separated sets: \"0,1;1,2;0,2\"" + ); +} + +#[test] +fn test_problem_help_uses_k_for_staff_scheduling() { + assert_eq!( + help_flag_name("StaffScheduling", "shifts_per_schedule"), + "k" + ); + assert_eq!( + problem_help_flag_name("StaffScheduling", "shifts_per_schedule", "usize", false), + "k" + ); +} + +#[test] +fn test_parse_bool_rows_reports_generic_invalid_boolean_entry() { + let err = parse_bool_rows("1,maybe").unwrap_err().to_string(); + assert_eq!( + err, + "Invalid boolean entry 'maybe': expected 0/1 or true/false" + ); +} + +#[test] +fn test_create_staff_scheduling_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "StaffScheduling", + "--schedules", + "1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1", + "--requirements", + "2,2,2,3,3,2,1", + "--num-workers", + "4", + "--k", + "5", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let output_path = std::env::temp_dir().join(format!("staff-scheduling-create-{suffix}.json")); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(json["type"], "StaffScheduling"); + assert_eq!(json["data"]["num_workers"], 4); + assert_eq!( + json["data"]["requirements"], + serde_json::json!([2, 2, 2, 3, 3, 2, 1]) + ); + std::fs::remove_file(output_path).unwrap(); +} + +#[test] +fn test_create_path_constrained_network_flow_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PathConstrainedNetworkFlow", + "--arcs", + "0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7", + "--capacities", + "2,1,1,1,1,1,1,1,2,1", + "--source", + "0", + "--sink", + "7", + "--paths", + "0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9", + "--requirement", + "3", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("path_constrained_network_flow"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create PathConstrainedNetworkFlow JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "PathConstrainedNetworkFlow"); + assert_eq!(created["data"]["source"], 0); + assert_eq!(created["data"]["sink"], 7); + assert_eq!(created["data"]["requirement"], 3); + assert_eq!(created["data"]["paths"][0], serde_json::json!([0, 2, 5, 8])); +} + +#[test] +fn test_create_path_constrained_network_flow_rejects_invalid_paths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "PathConstrainedNetworkFlow", + "--arcs", + "0>1,1>2,2>3", + "--capacities", + "1,1,1", + "--source", + "0", + "--sink", + "3", + "--paths", + "0,3", + "--requirement", + "1", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("out of bounds") || err.contains("not contiguous")); +} + +#[test] +fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "StaffScheduling", + "--schedules", + "1,1,1,1,1,0,0;0,1,1,1,1,1", + "--requirements", + "2,2,2,3,3,2,1", + "--num-workers", + "4", + "--k", + "5", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let result = std::panic::catch_unwind(|| create(&args, &out)); + assert!(result.is_ok(), "create should return an error, not panic"); + let err = result.unwrap().unwrap_err().to_string(); + // parse_bool_rows catches ragged rows before validate_staff_scheduling_args + assert!( + err.contains("All rows") || err.contains("schedule 1 has 6 periods, expected 7"), + "expected row-length validation error, got: {err}" + ); +} + +#[test] +fn test_problem_help_uses_num_tasks_for_timetable_design() { + assert_eq!( + problem_help_flag_name("TimetableDesign", "num_tasks", "usize", false), + "num-tasks" + ); + assert_eq!( + help_flag_hint("TimetableDesign", "craftsman_avail", "Vec>", None), + "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" + ); +} + +#[test] +fn test_example_for_path_constrained_network_flow_mentions_paths_flag() { + let example = example_for("PathConstrainedNetworkFlow", None); + assert!(example.contains("--paths")); + assert!(example.contains("--requirement")); +} + +#[test] +fn test_example_for_three_partition_mentions_sizes_and_bound() { + let example = example_for("ThreePartition", None); + assert!(example.contains("--sizes")); + assert!(example.contains("--bound")); +} + +#[test] +fn test_create_three_partition_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ThreePartition", + "--sizes", + "4,5,6,4,6,5", + "--bound", + "15", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("three_partition_create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create ThreePartition JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "ThreePartition"); + assert_eq!( + created["data"]["sizes"], + serde_json::json!([4, 5, 6, 4, 6, 5]) + ); + assert_eq!(created["data"]["bound"], 15); +} + +#[test] +fn test_create_three_partition_requires_bound() { + let cli = Cli::try_parse_from(["pred", "create", "ThreePartition", "--sizes", "4,5,6,4,6,5"]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("ThreePartition requires --bound")); +} + +#[test] +fn test_create_three_partition_rejects_invalid_instance() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ThreePartition", + "--sizes", + "4,5,6,4,6,5", + "--bound", + "14", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("must equal m * bound")); +} + +#[test] +fn test_create_timetable_design_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "TimetableDesign", + "--num-periods", + "3", + "--num-craftsmen", + "5", + "--num-tasks", + "5", + "--craftsman-avail", + "1,1,1;1,1,0;0,1,1;1,0,1;1,1,1", + "--task-avail", + "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", + "--requirements", + "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let output_path = std::env::temp_dir().join(format!("timetable-design-create-{suffix}.json")); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(json["type"], "TimetableDesign"); + assert_eq!(json["data"]["num_periods"], 3); + assert_eq!(json["data"]["num_craftsmen"], 5); + assert_eq!(json["data"]["num_tasks"], 5); + assert_eq!( + json["data"]["craftsman_avail"], + serde_json::json!([ + [true, true, true], + [true, true, false], + [false, true, true], + [true, false, true], + [true, true, true] + ]) + ); + assert_eq!( + json["data"]["task_avail"], + serde_json::json!([ + [true, true, false], + [false, true, true], + [true, false, true], + [true, true, true], + [true, true, true] + ]) + ); + assert_eq!( + json["data"]["requirements"], + serde_json::json!([ + [1, 0, 1, 0, 0], + [0, 1, 0, 0, 1], + [0, 0, 0, 1, 0], + [0, 0, 0, 0, 1], + [0, 1, 0, 0, 0] + ]) + ); + std::fs::remove_file(output_path).unwrap(); +} + +#[test] +fn test_create_timetable_design_reports_invalid_matrix_without_panic() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "TimetableDesign", + "--num-periods", + "3", + "--num-craftsmen", + "5", + "--num-tasks", + "5", + "--craftsman-avail", + "1,1,1;1,1", + "--task-avail", + "1,1,0;0,1,1;1,0,1;1,1,1;1,1,1", + "--requirements", + "1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let result = std::panic::catch_unwind(|| create(&args, &out)); + assert!(result.is_ok(), "create should return an error, not panic"); + let err = result.unwrap().unwrap_err().to_string(); + assert!( + err.contains("--craftsman-avail"), + "expected timetable matrix validation error, got: {err}" + ); + assert!(err.contains("Usage: pred create TimetableDesign")); +} + +#[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")); +} + +#[test] +fn test_create_capacity_assignment_serializes_problem_json() { + let output = temp_output_path("capacity_assignment_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "CapacityAssignment", + "--capacities", + "1,2,3", + "--cost-matrix", + "1,3,6;2,4,7;1,2,5", + "--delay-matrix", + "8,4,1;7,3,1;6,3,1", + "--delay-budget", + "12", + ]) + .expect("parse create command"); + 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"], "CapacityAssignment"); + assert_eq!(json["data"]["capacities"], serde_json::json!([1, 2, 3])); + assert_eq!(json["data"]["delay_budget"], 12); +} + +#[test] +fn test_create_production_planning_serializes_problem_json() { + let output = temp_output_path("production_planning_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "ProductionPlanning", + "--num-periods", + "6", + "--demands", + "5,3,7,2,8,5", + "--capacities", + "12,12,12,12,12,12", + "--setup-costs", + "10,10,10,10,10,10", + "--production-costs", + "1,1,1,1,1,1", + "--inventory-costs", + "1,1,1,1,1,1", + "--cost-bound", + "80", + ]) + .expect("parse create command"); + 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"], "ProductionPlanning"); + assert_eq!(json["data"]["num_periods"], 6); + assert_eq!( + json["data"]["demands"], + serde_json::json!([5, 3, 7, 2, 8, 5]) + ); + assert_eq!( + json["data"]["capacities"], + serde_json::json!([12, 12, 12, 12, 12, 12]) + ); + assert_eq!( + json["data"]["setup_costs"], + serde_json::json!([10, 10, 10, 10, 10, 10]) + ); + assert_eq!( + json["data"]["production_costs"], + serde_json::json!([1, 1, 1, 1, 1, 1]) + ); + assert_eq!( + json["data"]["inventory_costs"], + serde_json::json!([1, 1, 1, 1, 1, 1]) + ); + assert_eq!(json["data"]["cost_bound"], 80); +} + +#[test] +fn test_create_production_planning_requires_all_period_vectors() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ProductionPlanning", + "--num-periods", + "6", + "--demands", + "5,3,7,2,8,5", + "--capacities", + "12,12,12,12,12,12", + "--setup-costs", + "10,10,10,10,10,10", + "--inventory-costs", + "1,1,1,1,1,1", + "--cost-bound", + "80", + ]) + .expect("parse create command"); + 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("ProductionPlanning requires --production-costs")); +} + +#[test] +fn test_create_production_planning_rejects_mismatched_period_lengths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "ProductionPlanning", + "--num-periods", + "6", + "--demands", + "5,3,7,2,8", + "--capacities", + "12,12,12,12,12,12", + "--setup-costs", + "10,10,10,10,10,10", + "--production-costs", + "1,1,1,1,1,1", + "--inventory-costs", + "1,1,1,1,1,1", + "--cost-bound", + "80", + ]) + .expect("parse create command"); + 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("--demands must contain exactly 6 entries")); +} + +#[test] +fn test_create_example_production_planning_uses_canonical_example() { + let output = temp_output_path("production_planning_example_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "--example", + "ProductionPlanning", + ]) + .expect("parse create command"); + 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"], "ProductionPlanning"); + assert_eq!(json["data"]["num_periods"], 4); + assert_eq!(json["data"]["demands"], serde_json::json!([2, 1, 3, 2])); + assert_eq!(json["data"]["cost_bound"], 16); +} + +#[test] +fn test_create_longest_path_serializes_problem_json() { + let output = temp_output_path("longest_path_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "LongestPath", + "--graph", + "0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6", + "--edge-lengths", + "3,2,4,1,5,2,3,2,4,1", + "--source-vertex", + "0", + "--target-vertex", + "6", + ]) + .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"], "LongestPath"); + assert_eq!(json["variant"]["graph"], "SimpleGraph"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["source_vertex"], 0); + assert_eq!(json["data"]["target_vertex"], 6); + assert_eq!( + json["data"]["edge_lengths"], + serde_json::json!([3, 2, 4, 1, 5, 2, 3, 2, 4, 1]) + ); +} + +#[test] +fn test_create_undirected_flow_lower_bounds_serializes_problem_json() { + let output = temp_output_path("undirected_flow_lower_bounds_create"); + let cli = Cli::try_parse_from([ + "pred", + "-o", + output.to_str().unwrap(), + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3,1-4,3-5,4-5", + "--capacities", + "2,2,2,2,1,3,2", + "--lower-bounds", + "1,1,0,0,1,0,1", + "--source", + "0", + "--sink", + "5", + "--requirement", + "3", + ]) + .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"], "UndirectedFlowLowerBounds"); + assert_eq!(json["data"]["source"], 0); + assert_eq!(json["data"]["sink"], 5); + assert_eq!(json["data"]["requirement"], 3); + assert_eq!( + json["data"]["lower_bounds"], + serde_json::json!([1, 1, 0, 0, 1, 0, 1]) + ); +} + +#[test] +fn test_create_capacity_assignment_rejects_non_monotone_cost_row() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "CapacityAssignment", + "--capacities", + "1,2,3", + "--cost-matrix", + "1,3,2;2,4,7;1,2,5", + "--delay-matrix", + "8,4,1;7,3,1;6,3,1", + "--delay-budget", + "12", + ]) + .expect("parse create command"); + 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().to_string(); + assert!(err.contains("cost row 0")); + assert!(err.contains("non-decreasing")); +} + +#[test] +fn test_create_capacity_assignment_rejects_matrix_width_mismatch() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "CapacityAssignment", + "--capacities", + "1,2,3", + "--cost-matrix", + "1,3;2,4,7;1,2,5", + "--delay-matrix", + "8,4,1;7,3,1;6,3,1", + "--delay-budget", + "12", + ]) + .expect("parse create command"); + 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().to_string(); + assert!(err.contains("cost row 0")); + assert!(err.contains("capacities length")); +} + +#[test] +fn test_create_longest_path_requires_edge_lengths() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--source-vertex", + "0", + "--target-vertex", + "2", + ]) + .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("LongestPath requires --edge-lengths")); +} + +#[test] +fn test_create_longest_path_rejects_weights_flag() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "LongestPath", + "--graph", + "0-1,1-2", + "--weights", + "1,1,1", + "--source-vertex", + "0", + "--target-vertex", + "2", + "--edge-lengths", + "5,7", + ]) + .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("LongestPath uses --edge-lengths, not --weights")); +} + +#[test] +fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "UndirectedFlowLowerBounds", + "--graph", + "0-1,0-2,1-3,2-3,1-4,3-5,4-5", + "--capacities", + "2,2,2,2,1,3,2", + "--source", + "0", + "--sink", + "5", + "--requirement", + "3", + ]) + .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("UndirectedFlowLowerBounds requires --lower-bounds")); +} + +fn empty_args() -> CreateArgs { + CreateArgs { + problem: Some("BiconnectivityAugmentation".to_string()), + example: None, + example_target: None, + example_side: crate::cli::ExampleSide::Source, + graph: None, + weights: None, + edge_weights: None, + edge_lengths: None, + capacities: None, + demands: None, + setup_costs: None, + production_costs: None, + inventory_costs: None, + bundle_capacities: None, + cost_matrix: None, + delay_matrix: None, + lower_bounds: None, + multipliers: None, + source: None, + sink: None, + requirement: None, + num_paths_required: None, + paths: None, + couplings: None, + fields: None, + clauses: None, + disjuncts: None, + num_vars: None, + matrix: None, + k: None, + num_partitions: None, + random: false, + source_vertex: None, + target_vertex: None, + num_vertices: None, + edge_prob: None, + seed: None, + target: None, + m: None, + n: None, + positions: None, + radius: None, + source_1: None, + sink_1: None, + source_2: None, + sink_2: None, + requirement_1: None, + requirement_2: None, + sizes: None, + probabilities: None, + capacity: None, + sequence: None, + sets: None, + r_sets: None, + s_sets: None, + r_weights: None, + s_weights: None, + partition: None, + partitions: None, + bundles: None, + universe: None, + biedges: None, + left: None, + right: None, + rank: None, + basis: None, + target_vec: None, + bounds: None, + release_times: None, + lengths: None, + terminals: None, + terminal_pairs: None, + tree: None, + required_edges: None, + bound: None, + latency_bound: None, + length_bound: None, + weight_bound: None, + diameter_bound: None, + cost_bound: None, + delay_budget: None, + pattern: None, + strings: None, + string: None, + arc_costs: None, + arcs: None, + left_arcs: None, + right_arcs: None, + values: None, + precedences: None, + distance_matrix: None, + potential_edges: None, + budget: None, + max_cycle_length: None, + candidate_arcs: None, + deadlines: None, + precedence_pairs: None, + task_lengths: None, + job_tasks: None, + resource_bounds: None, + resource_requirements: None, + deadline: None, + num_processors: None, + alphabet_size: None, + deps: None, + query: None, + dependencies: None, + num_attributes: None, + source_string: None, + target_string: None, + schedules: None, + requirements: None, + num_workers: None, + num_periods: None, + num_craftsmen: None, + num_tasks: None, + craftsman_avail: None, + task_avail: None, + num_groups: None, + num_sectors: None, + domain_size: None, + relations: None, + conjuncts_spec: None, + relation_attrs: None, + known_keys: None, + num_objects: None, + attribute_domains: None, + frequency_tables: None, + known_values: None, + costs: None, + cut_bound: None, + size_bound: None, + usage: None, + storage: None, + quantifiers: None, + homologous_pairs: None, + pointer_cost: None, + expression: None, + coeff_a: None, + coeff_b: None, + rhs: None, + coeff_c: None, + pairs: None, + required_columns: None, + compilers: None, + setup_times: None, + w_sizes: None, + x_sizes: None, + y_sizes: None, + equations: None, + assignment: None, + initial_marking: None, + output_arcs: None, + gate_types: None, + true_sentences: None, + implications: None, + loop_length: None, + loop_variables: None, + inputs: None, + outputs: None, + assignments: None, + num_variables: None, + truth_table: None, + test_matrix: None, + num_tests: None, + tiles: None, + grid_size: None, + num_colors: None, + } +} + +#[test] +fn test_all_data_flags_empty_treats_potential_edges_as_input() { + let mut args = empty_args(); + args.potential_edges = Some("0-2:3,1-3:5".to_string()); + assert!(!all_data_flags_empty(&args)); +} + +#[test] +fn test_all_data_flags_empty_treats_budget_as_input() { + let mut args = empty_args(); + args.budget = Some("7".to_string()); + assert!(!all_data_flags_empty(&args)); +} + +#[test] +fn test_all_data_flags_empty_treats_max_cycle_length_as_input() { + let mut args = empty_args(); + args.max_cycle_length = Some(4); + assert!(!all_data_flags_empty(&args)); +} + +#[test] +fn test_all_data_flags_empty_treats_homologous_pairs_as_input() { + let mut args = empty_args(); + args.homologous_pairs = Some("2=5;4=3".to_string()); + assert!(!all_data_flags_empty(&args)); +} + +#[test] +fn test_all_data_flags_empty_treats_job_tasks_as_input() { + let mut args = empty_args(); + args.job_tasks = Some("0:1,1:1;1:1,0:1".to_string()); + assert!(!all_data_flags_empty(&args)); +} + +#[test] +fn test_parse_potential_edges() { + let mut args = empty_args(); + args.potential_edges = Some("0-2:3,1-3:5".to_string()); + + let potential_edges = parse_potential_edges(&args).unwrap(); + + assert_eq!(potential_edges, vec![(0, 2, 3), (1, 3, 5)]); +} + +#[test] +fn test_parse_potential_edges_rejects_missing_weight() { + let mut args = empty_args(); + args.potential_edges = Some("0-2,1-3:5".to_string()); + + let err = parse_potential_edges(&args).unwrap_err().to_string(); + + assert!(err.contains("u-v:w")); +} + +#[test] +fn test_parse_budget() { + let mut args = empty_args(); + args.budget = Some("7".to_string()); + + assert_eq!(parse_budget(&args).unwrap(), 7); +} + +#[test] +fn test_create_disjoint_connecting_paths_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::DisjointConnectingPaths; + + let mut args = empty_args(); + args.problem = Some("DisjointConnectingPaths".to_string()); + args.graph = Some("0-1,1-3,0-2,1-4,2-4,3-5,4-5".to_string()); + args.terminal_pairs = Some("0-3,2-5".to_string()); + + let output_path = std::env::temp_dir().join(format!("dcp-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "DisjointConnectingPaths"); + assert_eq!( + created.variant, + BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]) + ); + + let problem: DisjointConnectingPaths = + serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 7); + assert_eq!(problem.terminal_pairs(), &[(0, 3), (2, 5)]); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { + let mut args = empty_args(); + args.problem = Some("DisjointConnectingPaths".to_string()); + args.graph = Some("0-1,1-2,2-3,3-4".to_string()); + args.terminal_pairs = Some("0-2,2-4".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("pairwise disjoint")); +} + +#[test] +fn test_parse_homologous_pairs() { + let mut args = empty_args(); + args.homologous_pairs = Some("2=5;4=3".to_string()); + + assert_eq!(parse_homologous_pairs(&args).unwrap(), vec![(2, 5), (4, 3)]); +} + +#[test] +fn test_parse_homologous_pairs_rejects_invalid_token() { + let mut args = empty_args(); + args.homologous_pairs = Some("2-5".to_string()); + + let err = parse_homologous_pairs(&args).unwrap_err().to_string(); + + assert!(err.contains("u=v")); +} + +#[test] +fn test_parse_graph_respects_explicit_num_vertices() { + let mut args = empty_args(); + args.graph = Some("0-1".to_string()); + args.num_vertices = Some(3); + + let (graph, num_vertices) = parse_graph(&args).unwrap(); + + assert_eq!(num_vertices, 3); + assert_eq!(graph.num_vertices(), 3); + assert_eq!(graph.edges(), vec![(0, 1)]); +} + +#[test] +fn test_validate_potential_edges_rejects_existing_graph_edge() { + let err = validate_potential_edges(&SimpleGraph::path(3), &[(0, 1, 5)]) + .unwrap_err() + .to_string(); + + assert!(err.contains("already exists in the graph")); +} + +#[test] +fn test_validate_potential_edges_rejects_duplicate_edges() { + let err = validate_potential_edges(&SimpleGraph::path(4), &[(0, 3, 1), (3, 0, 2)]) + .unwrap_err() + .to_string(); + + assert!(err.contains("Duplicate potential edge")); +} + +#[test] +fn test_create_biconnectivity_augmentation_json() { + let mut args = empty_args(); + args.graph = Some("0-1,1-2,2-3".to_string()); + args.potential_edges = Some("0-2:3,0-3:4,1-3:2".to_string()); + args.budget = Some("5".to_string()); + + let output_path = std::env::temp_dir().join("pred_test_create_biconnectivity.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "BiconnectivityAugmentation"); + assert_eq!(json["data"]["budget"], 5); + assert_eq!( + json["data"]["potential_weights"][0], + serde_json::json!([0, 2, 3]) + ); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_biconnectivity_augmentation_json_with_isolated_vertices() { + let mut args = empty_args(); + args.graph = Some("0-1".to_string()); + args.num_vertices = Some(3); + args.potential_edges = Some("1-2:1".to_string()); + args.budget = Some("1".to_string()); + + let output_path = std::env::temp_dir().join("pred_test_create_biconnectivity_isolated.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let problem: BiconnectivityAugmentation = + serde_json::from_value(json["data"].clone()).unwrap(); + + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.potential_weights(), &[(1, 2, 1)]); + assert_eq!(problem.budget(), &1); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_partial_feedback_edge_set_json() { + use problemreductions::models::graph::PartialFeedbackEdgeSet; + + let mut args = empty_args(); + args.problem = Some("PartialFeedbackEdgeSet".to_string()); + args.graph = Some("0-1,1-2,2-0".to_string()); + args.budget = Some("1".to_string()); + args.max_cycle_length = Some(3); + + let output_path = std::env::temp_dir().join("pred_test_create_partial_feedback_edge_set.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "PartialFeedbackEdgeSet"); + assert_eq!(json["data"]["budget"], 1); + assert_eq!(json["data"]["max_cycle_length"], 3); + + let problem: PartialFeedbackEdgeSet = + serde_json::from_value(json["data"].clone()).unwrap(); + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.budget(), 1); + assert_eq!(problem.max_cycle_length(), 3); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_partial_feedback_edge_set_requires_max_cycle_length() { + let mut args = empty_args(); + args.problem = Some("PartialFeedbackEdgeSet".to_string()); + args.graph = Some("0-1,1-2,2-0".to_string()); + args.budget = Some("1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("PartialFeedbackEdgeSet requires --max-cycle-length")); +} + +#[test] +fn test_create_ensemble_computation_json() { + let mut args = empty_args(); + args.problem = Some("EnsembleComputation".to_string()); + args.universe = Some(4); + args.sets = Some("0,1,2;0,1,3".to_string()); + args.budget = Some("4".to_string()); + + let output_path = std::env::temp_dir().join("pred_test_create_ensemble_computation.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "EnsembleComputation"); + assert_eq!(json["data"]["universe_size"], 4); + assert_eq!( + json["data"]["subsets"], + serde_json::json!([[0, 1, 2], [0, 1, 3]]) + ); + assert_eq!(json["data"]["budget"], 4); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_expected_retrieval_cost_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::misc::ExpectedRetrievalCost; + + let mut args = empty_args(); + args.problem = Some("ExpectedRetrievalCost".to_string()); + args.probabilities = Some("0.2,0.15,0.15,0.2,0.1,0.2".to_string()); + args.num_sectors = Some(3); + + let output_path = std::env::temp_dir().join(format!( + "expected-retrieval-cost-{}.json", + std::process::id() + )); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "ExpectedRetrievalCost"); + + let problem: ExpectedRetrievalCost = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_records(), 6); + assert_eq!(problem.num_sectors(), 3); + use problemreductions::types::Min; + assert!(matches!( + problem.evaluate(&[0, 1, 2, 1, 0, 2]), + Min(Some(_)) + )); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_job_shop_scheduling_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::misc::JobShopScheduling; + use problemreductions::traits::Problem; + use problemreductions::types::Min; + + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0:3,1:4;1:2,0:3,1:2;0:4,1:3;1:5,0:2;0:2,1:3,0:1".to_string()); + + let output_path = + std::env::temp_dir().join(format!("job-shop-scheduling-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "JobShopScheduling"); + assert!(created.variant.is_empty()); + + let problem: JobShopScheduling = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_processors(), 2); + assert_eq!(problem.num_jobs(), 5); + assert_eq!( + problem.evaluate(&[0, 0, 0, 0, 0, 0, 1, 3, 0, 1, 1, 0]), + Min(Some(19)) + ); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_job_shop_scheduling_requires_job_tasks() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.num_processors = Some(2); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("JobShopScheduling requires --jobs")); +} + +#[test] +fn test_create_job_shop_scheduling_rejects_malformed_operation() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0-3,1:4".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("expected 'processor:length'")); +} + +#[test] +fn test_create_job_shop_scheduling_rejects_consecutive_same_processor() { + let mut args = empty_args(); + args.problem = Some("JobShopScheduling".to_string()); + args.job_tasks = Some("0:1,0:1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("must use different processors")); +} + +#[test] +fn test_create_rooted_tree_storage_assignment_json() { + let mut args = empty_args(); + args.problem = Some("RootedTreeStorageAssignment".to_string()); + args.universe = Some(5); + args.sets = Some("0,2;1,3;0,4;2,4".to_string()); + args.bound = Some(1); + + let output_path = + std::env::temp_dir().join("pred_test_create_rooted_tree_storage_assignment.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "RootedTreeStorageAssignment"); + assert_eq!(json["data"]["universe_size"], 5); + assert_eq!( + json["data"]["subsets"], + serde_json::json!([[0, 2], [1, 3], [0, 4], [2, 4]]) + ); + assert_eq!(json["data"]["bound"], 1); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_stacker_crane_json() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5,3".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + + let output_path = std::env::temp_dir().join("pred_test_create_stacker_crane.json"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let content = std::fs::read_to_string(&output_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "StackerCrane"); + assert_eq!(json["data"]["num_vertices"], 6); + assert_eq!(json["data"]["arcs"][0], serde_json::json!([0, 4])); + assert_eq!(json["data"]["edge_lengths"][6], 3); + + std::fs::remove_file(output_path).ok(); +} + +#[test] +fn test_create_stacker_crane_rejects_mismatched_arc_lengths() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + args.bound = Some(20); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("Expected 5 arc costs but got 4")); +} + +#[test] +fn test_create_stacker_crane_rejects_out_of_range_vertices() { + let mut args = empty_args(); + args.problem = Some("StackerCrane".to_string()); + args.num_vertices = Some(5); + args.arcs = Some("0>4,2>5,5>1,3>0,4>3".to_string()); + args.graph = Some("0-1,1-2,2-3,3-5,4-5,0-3,1-5".to_string()); + args.arc_costs = Some("3,4,2,5,3".to_string()); + args.edge_lengths = Some("2,1,3,2,1,4,3".to_string()); + args.bound = Some(20); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("--num-vertices (5) is too small for the arcs")); +} + +#[test] +fn test_create_minimum_dummy_activities_pert_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::MinimumDummyActivitiesPert; + + let mut args = empty_args(); + args.problem = Some("MinimumDummyActivitiesPert".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>2,0>3,1>3,1>4,2>5".to_string()); + + let output_path = temp_output_path("minimum_dummy_activities_pert"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "MinimumDummyActivitiesPert"); + assert!(created.variant.is_empty()); + + let problem: MinimumDummyActivitiesPert = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 5); + + let _ = fs::remove_file(output_path); +} + +#[test] +fn test_create_minimum_dummy_activities_pert_rejects_cycles() { + let mut args = empty_args(); + args.problem = Some("MinimumDummyActivitiesPert".to_string()); + args.num_vertices = Some(3); + args.arcs = Some("0>1,1>2,2>0".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("requires the input graph to be a DAG")); +} + +#[test] +fn test_create_balanced_complete_bipartite_subgraph() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::BalancedCompleteBipartiteSubgraph; + + let mut args = empty_args(); + args.problem = Some("BalancedCompleteBipartiteSubgraph".to_string()); + args.biedges = Some("0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3".to_string()); + args.left = Some(4); + args.right = Some(4); + args.k = Some(3); + args.graph = None; + + let output_path = std::env::temp_dir().join(format!("bcbs-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "BalancedCompleteBipartiteSubgraph"); + assert!(created.variant.is_empty()); + + let problem: BalancedCompleteBipartiteSubgraph = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.left_size(), 4); + assert_eq!(problem.right_size(), 4); + assert_eq!(problem.num_edges(), 12); + assert_eq!(problem.k(), 3); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_balanced_complete_bipartite_subgraph_rejects_out_of_range_biedges() { + let mut args = empty_args(); + args.problem = Some("BalancedCompleteBipartiteSubgraph".to_string()); + args.biedges = Some("4-0".to_string()); + args.left = Some(4); + args.right = Some(4); + args.k = Some(3); + args.graph = None; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("out of bounds for left partition size 4")); +} + +#[test] +fn test_create_kclique() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::KClique; + + let mut args = empty_args(); + args.problem = Some("KClique".to_string()); + args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string()); + args.k = Some(3); + + let output_path = + std::env::temp_dir().join(format!("kclique-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "KClique"); + assert_eq!( + created.variant.get("graph").map(String::as_str), + Some("SimpleGraph") + ); + + let problem: KClique = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.k(), 3); + assert_eq!(problem.num_vertices(), 5); + assert!(problem.evaluate(&[0, 0, 1, 1, 1])); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_kclique_requires_valid_k() { + let mut args = empty_args(); + args.problem = Some("KClique".to_string()); + args.graph = Some("0-1,0-2,1-3,2-3,2-4,3-4".to_string()); + args.k = None; + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err(); + assert!( + err.to_string().contains("KClique requires --k"), + "unexpected error: {err}" + ); + + args.k = Some(6); + let err = create(&args, &out).unwrap_err(); + assert!( + err.to_string().contains("k must be <= graph num_vertices"), + "unexpected error: {err}" + ); +} + +#[test] +fn test_create_sparse_matrix_compression_json() { + use crate::dispatch::ProblemJsonOutput; + + let mut args = empty_args(); + args.problem = Some("SparseMatrixCompression".to_string()); + args.matrix = Some("1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0".to_string()); + args.bound = Some(2); + + let output_path = std::env::temp_dir().join(format!("smc-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "SparseMatrixCompression"); + assert!(created.variant.is_empty()); + assert_eq!( + created.data, + serde_json::json!({ + "matrix": [ + [true, false, false, true], + [false, true, false, false], + [false, false, true, false], + [true, false, false, false], + ], + "bound_k": 2, + }) + ); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_sparse_matrix_compression_requires_bound() { + let mut args = empty_args(); + args.problem = Some("SparseMatrixCompression".to_string()); + args.matrix = Some("1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("SparseMatrixCompression requires --matrix and --bound")); + assert!(err.contains("Usage: pred create SparseMatrixCompression")); +} + +#[test] +fn test_create_sparse_matrix_compression_rejects_zero_bound() { + let mut args = empty_args(); + args.problem = Some("SparseMatrixCompression".to_string()); + args.matrix = Some("1,0;0,1".to_string()); + args.bound = Some(0); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("bound >= 1")); +} + +#[test] +fn test_create_graph_partitioning_with_num_partitions() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::GraphPartitioning; + use problemreductions::topology::SimpleGraph; + + let cli = Cli::try_parse_from([ + "pred", + "create", + "GraphPartitioning", + "--graph", + "0-1,1-2,2-3,3-0", + "--num-partitions", + "2", + ]) + .unwrap(); + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let output_path = temp_output_path("graph-partitioning-create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "GraphPartitioning"); + let problem: GraphPartitioning = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 4); + + let _ = fs::remove_file(output_path); +} + +#[test] +fn test_create_nontautology_with_disjuncts_flag() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::formula::NonTautology; + + let cli = Cli::try_parse_from([ + "pred", + "create", + "NonTautology", + "--num-vars", + "3", + "--disjuncts", + "1,2,3;-1,-2,-3", + ]) + .unwrap(); + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let output_path = temp_output_path("non-tautology-create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "NonTautology"); + let problem: NonTautology = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.disjuncts(), &[vec![1, 2, 3], vec![-1, -2, -3]]); + + let _ = fs::remove_file(output_path); +} + +#[test] +fn test_create_consecutive_ones_matrix_augmentation_json() { + use crate::dispatch::ProblemJsonOutput; + + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0".to_string()); + args.bound = Some(2); + + let output_path = std::env::temp_dir().join(format!("coma-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "ConsecutiveOnesMatrixAugmentation"); + assert!(created.variant.is_empty()); + assert_eq!( + created.data, + serde_json::json!({ + "matrix": [ + [true, false, false, true, true], + [true, true, false, false, false], + [false, true, true, false, true], + [false, false, true, true, false], + ], + "bound": 2, + }) + ); + + let _ = std::fs::remove_file(output_path); +} + +#[test] +fn test_create_consecutive_ones_matrix_augmentation_requires_bound() { + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0;0,1".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("ConsecutiveOnesMatrixAugmentation requires --matrix and --bound")); + assert!(err.contains("Usage: pred create ConsecutiveOnesMatrixAugmentation")); +} + +#[test] +fn test_create_consecutive_ones_matrix_augmentation_negative_bound() { + let mut args = empty_args(); + args.problem = Some("ConsecutiveOnesMatrixAugmentation".to_string()); + args.matrix = Some("1,0;0,1".to_string()); + args.bound = Some(-1); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("nonnegative")); +} diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index 9f2ffb11..0f9b08a3 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -102,6 +102,7 @@ pub fn ser_kcoloring( } /// Serialize a KSatisfiability instance given clauses and validated k. +#[cfg(feature = "mcp")] pub fn ser_ksat( num_vars: usize, clauses: Vec, From 854ad6b6cbc97f06722cd6bb9250ebb74d5f313e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 17:23:09 +0800 Subject: [PATCH 12/13] docs: update skills and CLAUDE.md for schema-driven create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add-model Step 4.5: replace "add match arm" with schema-driven instructions - review-structural check 13: replace grep for problem name with schema field→flag verification - CLAUDE.md Extension Points: document schema-driven create convention Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 3 ++- .claude/skills/add-model/SKILL.md | 17 +++++++---------- .claude/skills/review-structural/SKILL.md | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 27ce2026..4dbe43c4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -172,9 +172,10 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI +- **CLI creation is schema-driven:** `pred create` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. New models need only: (1) matching CLI flags in `CreateArgs` + `flag_map()`, and (2) type parser support in `parse_field_value()` if using a new field type. No match arm in `create.rs` is needed. - Aggregate-only models are first-class in `declare_variants!`; aggregate-only reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers witness/config reductions today - Exact registry dispatch lives in `src/registry/`; alias resolution and partial/default variant resolution live in `problemreductions-cli/src/problem_name.rs` -- `pred create` UX lives in `problemreductions-cli/src/commands/create.rs` +- `pred create` schema-driven dispatch lives in `problemreductions-cli/src/commands/create.rs` (`create_schema_driven()`) - Canonical paper and CLI examples live in `src/example_db/model_builders.rs` and `src/example_db/rule_builders.rs` ## Conventions diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 0f00e738..95d89b0b 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -175,18 +175,15 @@ The CLI now loads, serializes, and brute-force solves problems through the core ## Step 4.5: Add CLI creation support -Update `problemreductions-cli/src/commands/create.rs` so `pred create ` works: +CLI creation is **schema-driven** — `pred create ` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. No match arm in `create.rs` is needed. -1. **Add a match arm** in the `create()` function's main `match canonical.as_str()` block. Parse CLI flags and construct the problem: - - Graph-based problems with vertex weights: add to the `"MaximumIndependentSet" | ... | "MaximalIS"` arm - - Problems with unique fields: add a new arm that parses the required flags and calls the constructor - - See existing arms for patterns (e.g., `"BinPacking"` for simple fields, `"MaximumSetPacking"` for set-based) +1. **Ensure CLI flags exist** in `problemreductions-cli/src/cli.rs` (`CreateArgs` struct) for each field in your `ProblemSchemaEntry`. The flag name must match the field name via `snake_case → kebab-case` (e.g., field `edge_weights` → flag `--edge-weights`). If a flag already exists with the right name, you're done. -2. **Add CLI flags** in `problemreductions-cli/src/cli.rs` (`CreateArgs` struct) if the problem needs flags not already present. Update `all_data_flags_empty()` accordingly. +2. **Add new CLI flags** only if the problem needs flags not already present. Add them to `CreateArgs` and update `all_data_flags_empty()` accordingly. Also add entries to the `flag_map()` method on `CreateArgs`. -3. **Update help text** in `CreateArgs`'s `after_help` — add the new problem to the "Flags by problem type" table in `problemreductions-cli/src/cli.rs` (search for `Flags by problem type`). +3. **Add type parser support** if the field uses a type not yet handled by `parse_field_value()` in `create.rs`. Check the existing type dispatch table — most standard types (`Vec`, `Vec`, `Vec<(usize, usize)>`, graph types, etc.) are already covered. Only add a new parser for genuinely new types. -4. **Schema alignment**: The `ProblemSchemaEntry` fields should list **constructor parameters** (what the user provides), not internal derived fields. For example, if `m` and `n` are derived from a matrix, only list `matrix` and `k` in the schema. +4. **Schema alignment**: The `ProblemSchemaEntry` fields should list **constructor parameters** (what the user provides), not internal derived fields. For example, if `m` and `n` are derived from a matrix, only list `matrix` and `k` in the schema. Field names must match the struct field names exactly (used for JSON serialization and CLI flag mapping). ## Step 4.6: Add canonical model example to example_db @@ -315,8 +312,8 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Wrong `declare_variants!` syntax | Entries no longer use `opt` / `sat`; one entry per problem may be marked `default` | | Forgetting CLI alias | Must add lowercase entry in `problem_name.rs` `resolve_alias()` | | Inventing short aliases | Only use well-established literature abbreviations (MIS, SAT, TSP); do NOT invent new ones | -| Forgetting CLI create | Must add creation handler in `commands/create.rs` and flags in `cli.rs` | -| Missing from CLI help table | Must add entry to "Flags by problem type" table in `cli.rs` `after_help` | +| Forgetting CLI flags | Schema-driven create needs matching CLI flags in `CreateArgs` for each `ProblemSchemaEntry` field (snake_case → kebab-case). Also add to `flag_map()`. | +| Missing type parser | If the problem uses a new field type, add a handler in `parse_field_value()` in `create.rs` | | Schema lists derived fields | Schema should list constructor params, not internal fields (e.g., `matrix, k` not `matrix, m, n, k`) | | Missing canonical model example | Add a builder in `src/example_db/model_builders.rs` and keep it aligned with paper/example workflows | | Paper example not tested | Must include `test__paper_example` that verifies the exact instance, solution, and solution count shown in the paper | diff --git a/.claude/skills/review-structural/SKILL.md b/.claude/skills/review-structural/SKILL.md index c169ebcb..5c30e15b 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -62,7 +62,7 @@ Only run if review type includes "model". Given: problem name `P`, category `C`, | 10 | Re-exported in `models/mod.rs` | `Grep("{P}", "src/models/mod.rs")` | | 11 | Variant registration exists | `Grep("declare_variants!|VariantEntry", file)` | | 12 | CLI `resolve_alias` entry | `Grep("{P}", "problemreductions-cli/src/problem_name.rs")` | -| 13 | CLI `create` support | `Grep('"{P}"', "problemreductions-cli/src/commands/create.rs")` | +| 13 | CLI `create` support | Schema-driven: verify each `ProblemSchemaEntry` field has a matching CLI flag in `CreateArgs` (field `snake_case` → flag `kebab-case`). Check `flag_map()` includes the flag. If the field type is unusual, verify `parse_field_value()` handles it. | | 14 | Canonical model example registered | `Grep("{P}", "src/example_db/model_builders.rs")` | | 15 | Paper `display-name` entry | `Grep('"{P}"', "docs/paper/reductions.typ")` | | 16 | Paper `problem-def` block | `Grep('problem-def.*"{P}"', "docs/paper/reductions.typ")` | From 0701d415ea1bbefa625eed911ab87a20ebb445df Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sun, 5 Apr 2026 18:17:10 +0800 Subject: [PATCH 13/13] chore: dedup all_data_flags_empty, remove legacy flag_map aliases, enforce schema naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 16 duplicate field checks in all_data_flags_empty() - Remove backward-compat aliases ("sets", "query") from flag_map(); schema-derived names ("subsets", "query-attribute") are canonical - Map schema field "sets" → "subsets" in help_flag_name for consistency - Add CLAUDE.md rule: CLI flag names must match schema field names Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 1 + .../2026-04-05-schema-driven-create-design.md | 354 ------------------ problemreductions-cli/src/cli.rs | 10 +- problemreductions-cli/src/commands/create.rs | 16 - .../src/commands/create/schema_support.rs | 2 +- 5 files changed, 6 insertions(+), 377 deletions(-) delete mode 100644 docs/plans/2026-04-05-schema-driven-create-design.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4dbe43c4..126c83f6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -173,6 +173,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI - **CLI creation is schema-driven:** `pred create` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. New models need only: (1) matching CLI flags in `CreateArgs` + `flag_map()`, and (2) type parser support in `parse_field_value()` if using a new field type. No match arm in `create.rs` is needed. +- **CLI flag names must match schema field names.** The canonical name for a CLI flag is the schema field name in kebab-case (e.g., schema field `universe_size` → `--universe-size`, field `subsets` → `--subsets`). Old aliases (e.g., `--universe`, `--sets`) may exist as clap `alias` for backward compatibility at the clap level, but `flag_map()`, help text, error messages, and documentation must use the schema-derived name. Do not add new backward-compat aliases; if a field is renamed in the schema, update the CLI flag name to match. - Aggregate-only models are first-class in `declare_variants!`; aggregate-only reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers witness/config reductions today - Exact registry dispatch lives in `src/registry/`; alias resolution and partial/default variant resolution live in `problemreductions-cli/src/problem_name.rs` - `pred create` schema-driven dispatch lives in `problemreductions-cli/src/commands/create.rs` (`create_schema_driven()`) diff --git a/docs/plans/2026-04-05-schema-driven-create-design.md b/docs/plans/2026-04-05-schema-driven-create-design.md deleted file mode 100644 index 0704aef2..00000000 --- a/docs/plans/2026-04-05-schema-driven-create-design.md +++ /dev/null @@ -1,354 +0,0 @@ -# Schema-Driven `pred create` Refactor - -**Date:** 2026-04-05 -**Goal:** Replace the 11K-line `create.rs` with a schema-driven generic dispatch that uses the existing registry `factory` function, reducing the file by ~50%. -**Reviewed:** 2026-04-05 by Codex (see Appendix A for review findings) - -## Problem - -`problemreductions-cli/src/commands/create.rs` is 11,049 lines. The bulk is a 5,400-line `match canonical { ... }` that manually builds JSON for each of ~189 problems, plus 480 lines for `create_random`, plus 330 lines of lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`). - -The registry already has a `factory: fn(serde_json::Value) -> Result>` per variant that calls `serde_json::from_value()`. The 5,400 lines are manually doing what the factory can do generically. - -## Design - -### Phase 1: Align CLI Flags to Struct Field Names - -Rename ~20 CLI flags in `CreateArgs` so every flag name matches its problem struct field name via `snake_case → kebab-case`. This makes convention-based mapping 100% mechanical with zero exceptions. - -**Renames required:** - -| Current flag | Struct field | New flag | Problems affected | -|---|---|---|---| -| `--job-tasks` | `jobs` | `--jobs` | JobShopScheduling | -| `--source-string` | `source` | (keep, add alias `--source-string`) | StringToStringCorrection | -| `--target-string` | `target` | (keep, add alias `--target-string`) | StringToStringCorrection | -| `--sets` | `subsets` | `--subsets` | SetPacking, MinimumHittingSet, etc. (~8) | -| `--universe` | `universe_size` | `--universe-size` | SetBasis, Betweenness, etc. (~5) | -| `--arc-costs` | `arc_weights` / `arc_lengths` | `--arc-weights` / `--arc-lengths` | MixedChinesePostman, StackerCrane | -| `--deps` | `dependencies` | `--dependencies` | PrimeAttributeName | -| `--query` | `query_attribute` | `--query-attribute` | PrimeAttributeName | -| `--precedence-pairs` | `precedences` | `--precedences` (already has alias) | MinimumTardinessSequencing, etc. (~4) | -| `--sizes` (for lengths) | `lengths` | `--lengths` (already exists!) | MultiprocessorScheduling, etc. (~5) | -| `--n` (for num_tasks) | `num_tasks` | `--num-tasks` (already exists!) | TimetableDesign, etc. (~4) | -| `--potential-edges` | `potential_weights` | `--potential-weights` | BiconnectivityAugmentation | -| `--bound` (various) | `max_length` / `max_weight` / `bound_k` / `threshold` | match each field | ~6 problems | - -**Backward compat:** Add `#[arg(alias = "old-name")]` for renamed flags so existing scripts don't break. - -**Note on `--source`/`--sink`/`--target`:** These flags are shared across many problems with different field names (`source`, `source_vertex`, `target`, `sink`). For fields like `GeneralizedHex.target` (which currently uses `--sink`), we keep `--sink` as an alias after renaming. The `source`/`sink`/`target` flags already match field names for most graph problems. StringToStringCorrection's `source`/`target` fields conflict with the graph vertex `--source`/`--sink` flags, so we keep `--source-string`/`--target-string` as aliases while the field-matched flag takes precedence during schema dispatch. - -### Phase 2: Generic Type Parser Registry - -A small registry that maps resolved concrete type names to parse functions. These parse functions already exist — we're just organizing them for generic dispatch. - -```rust -/// Parse a CLI string value into a serde_json::Value based on the resolved concrete type. -fn parse_field_value( - concrete_type: &str, - field_name: &str, - raw: &str, - context: &CreateContext, // holds graph info for size validation -) -> Result -``` - -**Type dispatch table:** - -There are 52 unique `type_name` values across all schema registrations. They fall into three categories: - -**Category 1: Generic types (resolve via variant map first)** -These appear as literal type_name strings and must be resolved before dispatch: -- `G` → resolve via `variant["graph"]` → concrete graph type -- `W` → resolve via `variant["weight"]` → `One`, `i32`, or `f64` -- `W::Sum` → resolve `W` first, then map: `One` → `usize`, `i32` → `i32`, `f64` → `f64` -- `Vec`, `Vec>`, `Vec>`, `Vec<(usize, usize, W)>` → substitute generic param, then dispatch on resolved type - -**Note:** Whitespace in type_name strings is inconsistent (e.g., `Vec<(usize,usize)>` vs `Vec<(usize, usize)>`). Normalize by stripping spaces before matching. - -**Category 2: Concrete types with direct CLI parsing (~20 entries)** - -| Concrete type pattern | Parse strategy | Existing helper | -|---|---|---| -| `SimpleGraph` | edge list → `{num_vertices, edges}` | `parse_graph()` | -| `BipartiteGraph` | bipartite edge list | `parse_bipartite_graph()` | -| `KingsSubgraph` / `TriangularSubgraph` | positions → grid subgraph | `parse_grid_subgraph()` | -| `UnitDiskGraph` | positions + radius | `parse_unit_disk_graph()` | -| `DirectedGraph` | arc list → `{num_vertices, arcs}` | `parse_directed_graph()` | -| `MixedGraph` | graph + arcs | `parse_mixed_graph()` | -| `Vec` / `Vec` / `Vec` / `Vec` / `Vec` | comma-separated numbers | `parse_numeric_list::()` | -| `Vec` | auto-fill unit weights (length from context) | fill with `1`s | -| `Vec` | comma-separated 0/1 or true/false | `parse_bool_list()` | -| `Vec>` / `Vec>` / `Vec>` / `Vec>` / `Vec>` | semicolon-separated rows | `parse_nested_list::()` | -| `Vec>>` | pipe-separated matrices | `parse_3d_list()` | -| `Vec<[usize; 3]>` | semicolon-separated triples | `parse_triple_list()` | -| `Vec` | semicolon-separated signed literals | `parse_clauses()` | -| `Vec<(usize, usize)>` | pair list (comma or `>` separated) | `parse_pair_list()` | -| `Vec<(usize, f64)>` | pair list | `parse_typed_pair_list()` | -| `Vec<(usize, usize, usize)>` / `Vec<(usize, usize, usize, usize)>` | tuple list | `parse_tuple_list()` | -| `Vec<(usize, Vec)>` / `Vec<(Vec, Vec)>` / `Vec<(Vec, usize)>` | nested pair list | `parse_complex_pair_list()` | -| `Vec>` | job-task format | `parse_job_shop_jobs()` | -| `Vec` | semicolon-separated strings | `split(';')` | -| `Vec` / `BigUint` | comma-separated decimal strings | custom decimal parse (uses biguint_serde) | -| `Vec>` | comma-separated with "?" for None | `parse_optional_bool_list()` | -| `usize` / `u64` / `i32` / `i64` / `f64` | single number parse | `str::parse::()` | -| `One` (scalar unit weight) | skip field / default to `null` | (handled by serde default) | -| `bool` | "true"/"false" parse | `str::parse::()` | - -**Category 3: Complex domain types (passthrough as JSON)** -These types have custom serde and are best handled by passing the raw CLI string through the existing problem-specific parser, or by skipping CLI creation entirely (like ILP/CircuitSAT): -- `Circuit` → CircuitSAT (already excluded from CLI create) -- `IntExpr` → IntegerExpressionMembership (JSON expression tree via `--expression`) -- `ObjectiveSense` → ILP (already excluded from CLI create) -- `Vec` → ILP (already excluded from CLI create) -- `Vec` → QBF (comma-separated E/A via `--quantifiers`) -- `Vec` → ConjunctiveBooleanQuery (custom format via `--relations`) -- `Vec` → ConsistencyOfDatabaseFrequencyTables (custom format via `--frequency-tables`) -- `Vec` → ConsistencyOfDatabaseFrequencyTables (custom format via `--known-values`) -- `Vec` → ClosestVectorProblem (custom format via `--bounds`) -- `Vec<(usize, Vec)>` → ConjunctiveBooleanQuery (custom format via `--conjuncts-spec`) -- `Vec<(usize, Vec)>` → ConjunctiveQueryFoldability (tagged enum JSON) - -For Category 3, the parse function dispatches on `(problem_name, field_name)` rather than type alone, since these formats are problem-specific. This is a small lookup table (~10 entries) separate from the generic type dispatch. - -### Phase 3: Generic `create()` Function - -Replace the 5,400-line match with: - -```rust -pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { - // Existing: example path, ILP/CircuitSAT rejection, random path - if args.example.is_some() { return create_from_example(args, out); } - // ... resolve canonical name, variant ... - if args.random { return create_random(args, canonical, &resolved_variant, out); } - - // NEW: schema-driven path - let schema = find_schema(canonical) - .ok_or_else(|| anyhow!("No schema for {canonical}"))?; - let variant_entry = find_variant_entry(canonical, &resolved_variant)?; - - // Show help if no data flags provided - if all_data_flags_empty(args) { - print_schema_help(canonical, &schema, &resolved_variant)?; - std::process::exit(2); - } - - // Build JSON from schema fields - let mut json_map = serde_json::Map::new(); - let mut context = CreateContext::default(); - - for field in &schema.fields { - let flag_name = field.name.replace('_', "-"); // convention - let raw_value = get_flag_value(args, &flag_name); - let concrete_type = resolve_type(&field.type_name, &resolved_variant); - - let value = parse_field_value(&concrete_type, field.name, raw_value, &context)?; - - // Track graph context for downstream validation - if is_graph_type(&concrete_type) { - context.num_vertices = extract_num_vertices(&value); - context.num_edges = extract_num_edges(&value); - } - - json_map.insert(field.name.to_string(), value); - } - - // Run optional per-problem validator - if let Some(validator) = find_validator(canonical) { - validator(&json_map, args)?; - } - - // Factory deserializes JSON → concrete problem type - let json = serde_json::Value::Object(json_map); - let problem = (variant_entry.factory)(json) - .map_err(|e| anyhow!("Failed to construct {canonical}: {e}"))?; - - emit_dyn_problem_output(&problem, canonical, &resolved_variant, out) -} -``` - -### Phase 4: `get_flag_value()` — Reflective Flag Access - -The `CreateArgs` struct has ~120 `Option` fields. We need to look up a field by name at runtime. Two approaches: - -**Option A (recommended): Build a `HashMap<&str, Option<&str>>` from CreateArgs.** - -Add a method to `CreateArgs`: -```rust -impl CreateArgs { - fn flag_map(&self) -> HashMap<&str, Option<&str>> { - let mut m = HashMap::new(); - m.insert("graph", self.graph.as_deref()); - m.insert("weights", self.weights.as_deref()); - m.insert("edge-weights", self.edge_weights.as_deref()); - // ... all string flags - m - } -} -``` - -This is ~120 lines but purely mechanical and can be generated by a macro or build script. It replaces 5,400 lines. - -**Option B: Use serde to serialize CreateArgs to JSON, then look up fields by name.** - -Derive `Serialize` on `CreateArgs`, serialize to `serde_json::Value`, then access fields by name. Zero boilerplate but adds serde dependency to the CLI args struct. - -**Recommendation:** Option A for explicitness. Option B as fallback if the mechanical list becomes a maintenance burden. - -### Phase 5: Help Text Generation - -Replace the 330-line lookup tables (`example_for`, `help_flag_name`, `help_flag_hint`, `type_format_hint`) with schema-driven help: - -```rust -fn print_schema_help(canonical: &str, schema: &ProblemSchemaEntry, variant: &BTreeMap) -> Result<()> { - eprintln!("Usage: pred create {canonical} [FLAGS]\n"); - eprintln!("Fields:"); - for field in &schema.fields { - let flag = field.name.replace('_', "-"); - let concrete = resolve_type(&field.type_name, variant); - let format = type_format_hint_generic(&concrete); - eprintln!(" --{flag:<25} {:<20} {}", concrete, field.description); - if !format.is_empty() { - eprintln!(" {:<27} Format: {format}", ""); - } - } - // Show canonical example from example_db - if let Some(example) = find_model_example(canonical) { - eprintln!("\nExample:\n pred create {canonical} {}", example.cli_string()); - } - Ok(()) -} -``` - -**`example_for()` elimination:** Delegate to existing `canonical_model_example_specs()` from `src/example_db/model_builders.rs` instead of maintaining a parallel 300-line string table. - -### Phase 6: `create_random` Simplification - -The 480-line `create_random` also has a giant match. For most problems, random creation follows a pattern: -1. Create random graph (with `util::create_random_graph()`) -2. Create random weights (if needed) -3. Construct the problem - -This can be partially genericized using the same schema-driven approach, but random creation involves more problem-specific logic (e.g., SteinerTree needs random terminal selection). Keep the match for now but reduce it by extracting shared patterns into helpers for graph-only, graph+vertex-weight, and graph+edge-weight categories. Target: reduce from 480 to ~200 lines. - -### Phase 7: Per-Problem Validators - -~15-20 problems need custom validation beyond type parsing: - -```rust -type ValidatorFn = fn(&serde_json::Map, &CreateArgs) -> Result<()>; - -fn find_validator(canonical: &str) -> Option { - match canonical { - "GeneralizedHex" => Some(|json, _| { - let source = json["source"].as_u64().unwrap(); - let target = json["target"].as_u64().unwrap(); - if source == target { bail!("source and target must be distinct"); } - Ok(()) - }), - "LengthBoundedDisjointPaths" => Some(validate_lbdp), - // ~15 more - _ => None, - } -} -``` - -This is ~200 lines — the genuinely unique validation logic that can't be eliminated. - -### Phase 8: Non-String Flag Handling - -Some `CreateArgs` fields are non-string types (`Option`, `Option`, `Option`, `bool`). These need special handling in `get_flag_value()`: - -- `source: Option` → convert to string for the generic path -- `k: Option` → same -- `bound: Option` → same -- `random: bool`, `seed: Option`, `edge_prob: Option` → only used by `create_random`, not the schema path - -The `flag_map()` can include these by converting to string: `m.insert("source", self.source.map(|v| v.to_string()))`. Slight ugliness but keeps the generic path uniform. - -Alternatively, keep these as special-case lookups outside the generic loop (they affect <10 problems). - -## File Structure After Refactor - -``` -problemreductions-cli/src/commands/create.rs (~3,000 lines → from 11,049) -├── create() — generic schema-driven dispatch (~80 lines) -├── create_from_example() — unchanged (~40 lines) -├── create_random() — simplified (~200 lines, down from 480) -├── CreateContext — tracking struct for cross-field validation (~20 lines) -├── Type parsers — parse_field_value() + ~15 type handlers (~400 lines) -├── Flag access — flag_map() or equivalent (~130 lines) -├── Help generation — schema-driven help (~60 lines) -├── Validators — per-problem validation (~200 lines) -├── Existing helpers — parse_graph, parse_clauses, etc. (~1,500 lines, kept) -└── Graph parsing utilities — parse_edge_list, etc. (~400 lines, kept) -``` - -**Estimated reduction:** 11,049 → ~3,000 lines (~73% reduction). - -## Serde Edge Cases - -Several problems use serde customization that affects the factory JSON path: - -**`#[serde(try_from)]` — validation wrappers (6 problems):** -`NAESatisfiability`, `StackerCrane`, `SetSplitting`, `RootedTreeStorageAssignment`, `EnsembleComputation`, `ConsecutiveBlockMinimization`. These keep the same JSON keys but reject invalid input after parsing. The factory will return an error with a validation message — this is correct behavior and needs no special handling. - -**`#[serde(from)]` + skip/default — cache fields:** -`BalancedCompleteBipartiteSubgraph` uses `#[serde(from)]` with internal cache fields. `KColoring` has `#[serde(default)]` + `#[serde(skip)]` on internal fields. These are transparent to the JSON input — the schema-exposed fields are the only ones needed. - -**`#[serde(skip)]` — phantom/const fields:** -`KSatisfiability` and `ILP` skip phantom type fields. These don't appear in JSON and need no CLI parsing. - -**Custom serde for `BigUint`:** -`SubsetSum` and `SubsetProduct` use custom decimal-string serde for `BigUint` fields (`biguint_serde` module). The CLI parser should produce decimal strings matching this format. - -**Tagged enum types:** -`Term` in `ConjunctiveQueryFoldability` serializes as a tagged JSON object, not a plain scalar. The CLI flag passes raw JSON for this type. - -## Risks and Mitigations - -| Risk | Mitigation | -|---|---| -| JSON shape mismatch (field order, missing defaults) | Factory uses `serde_json::from_value` which handles field order. Add integration tests comparing old vs new output for all 177 problems. | -| Generic type resolution fails for complex types | Start with a whitelist of known type patterns. Fall back to problem-specific match arm for unrecognized types. | -| Flag rename breaks external scripts | Add `#[arg(alias = "old-name")]` for all renames. | -| Error messages degrade (generic vs problem-specific) | Include problem name and field name in all error messages. Per-problem validators can add context. | -| `create_random` is harder to genericize | Phase 6 is conservative — extract helpers but keep the match. Revisit later. | - -## Testing Strategy - -1. **Regression tests:** For each of the 177 problems, compare `pred create ` output before and after the refactor. Use the existing `example_for()` args as test inputs. -2. **Round-trip tests:** `pred create X --args | pred solve -` must still work for all problems with ILP paths. -3. **Help text tests:** Verify `pred create ` (no args) produces useful help for 10+ diverse problems. -4. **Flag alias tests:** Verify old flag names still work via aliases. -5. **CLI demo:** `make cli-demo` must pass (exercises all commands). - -## Implementation Order - -1. **Write regression test harness** — capture current output for all 189 problems -2. **Rename CLI flags** — add aliases for backward compat (see Phase 1 + Appendix A for complete list) -3. **Implement `flag_map()`** — reflective flag access -4. **Implement type parser registry** — `parse_field_value()` with all 3 categories of type handlers -5. **Implement generic `create()`** — schema-driven dispatch -6. **Implement schema-driven help** — replace lookup tables -7. **Add per-problem validators** — ~15-20 problem-specific checks -8. **Simplify `create_random`** — extract shared patterns -9. **Run regression tests** — verify all 189 problems produce identical output -10. **Remove dead code** — old match arms, old lookup tables - -## Appendix A: Codex Review Findings (2026-04-05) - -### Additional Flag→Field Mismatches (not in Phase 1 table) - -The following field names have no matching CLI flag via `snake_case → kebab-case`. Each needs either a new flag or a rename: - -**Algebraic:** `ILP` (`constraints`, `objective`, `sense` — already excluded from CLI), `QuadraticCongruences` (`a`, `b`, `c`), `QuadraticDiophantineEquations` (`a`, `b`, `c`) - -**Graph:** `AcyclicPartition` (`vertex_weights`), `BicliqueCover` (`left_size`, `right_size`, `edges`), `BoundedComponentSpanningForest` (`max_components`), `DegreeConstrainedSpanningTree` (`max_degree`), `LengthBoundedDisjointPaths` (`max_paths`), `MinMaxMulticenter` (`vertex_weights`), `MinimumCapacitatedSpanningTree` (`root`), `MinimumEdgeCostFlow` (`prices`, `required_flow`), `MinimumSumMulticenter` (`vertex_weights`), `PartitionIntoCliques` (`num_cliques`), `PartitionIntoForests` (`num_forests`), `PartitionIntoPerfectMatchings` (`num_matchings`) - -**Misc:** `Betweenness` (`num_elements`, `triples`), `BoyceCoddNormalFormViolation` (`functional_deps`, `target_subset`), `CapacityAssignment` (`cost`, `delay`), `Clustering` (`distances`, `num_clusters`), `ConjunctiveBooleanQuery` (`conjuncts`), `ConjunctiveQueryFoldability` (`num_distinguished`, `num_undistinguished`, `relation_arities`, `query1_conjuncts`, `query2_conjuncts`), `CyclicOrdering` (`num_elements`, `triples`), `DynamicStorageAllocation` (`items`, `memory_size`), `FeasibleRegisterAssignment` (`num_registers`), `MinimumAxiomSet` (`num_sentences`), `MinimumCodeGenerationOneRegister` (`edges`, `num_leaves`), `MinimumRegisterSufficiencyForLoops` (`variables`), `NonLivenessFreePetriNet` (`num_places`, `num_transitions`, `place_to_transition`, `transition_to_place`), `Numerical3DimensionalMatching` (`sizes_w`, `sizes_x`, `sizes_y`), `NumericalMatchingWithTargetSums` (`sizes_x`, `sizes_y`, `targets`), `OpenShopScheduling` (`num_machines`, `processing_times`), `StackerCrane` (`edges`), `StaffScheduling` (`shifts_per_schedule`) - -**Set:** `SetBasis` (`collection`), `ThreeDimensionalMatching` (`triples`), `ThreeMatroidIntersection` (`ground_set_size`) - -### Implementation Note - -Many of these "mismatches" are fields that already have CLI flags under a *different* name (e.g., `vertex_weights` → `--weights`, `max_components` → `--k`). The Phase 1 rename aligns them. Some fields use generic flags like `--bound` that map to multiple field names — Phase 1 splits these into specific flags matching each field name. Fields for ILP and CircuitSAT are excluded since those problems already reject CLI creation. diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 73e8bbb5..542f7f46 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -281,7 +281,7 @@ Flags by problem type: ThreeMatroidIntersection --universe-size, --partitions, --bound SetBasis --universe-size, --subsets, --k MinimumCardinalityKey --num-attributes, --dependencies - PrimeAttributeName --universe, --dependencies, --query-attribute + PrimeAttributeName --universe-size, --dependencies, --query-attribute RootedTreeStorageAssignment --universe-size, --subsets, --bound TwoDimensionalConsecutiveSets --alphabet-size, --subsets BicliqueCover --left, --right, --biedges, --k @@ -394,8 +394,8 @@ Examples: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6 pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\" - pred create X3C --universe-size 9 --subsets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" - pred create SetBasis --universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3 + pred create X3C --universe 9 --subsets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" + pred create SetBasis --universe 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3 pred create MinimumCardinalityKey --num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\" pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3 pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"")] @@ -976,7 +976,6 @@ impl CreateArgs { insert!("capacity", self.capacity.as_deref()); insert!("sequence", self.sequence.as_deref()); insert!("subsets", self.sets.as_deref()); - insert!("sets", self.sets.as_deref()); insert!("r-sets", self.r_sets.as_deref()); insert!("s-sets", self.s_sets.as_deref()); insert!("r-weights", self.r_weights.as_deref()); @@ -985,7 +984,7 @@ impl CreateArgs { insert!("partitions", self.partitions.as_deref()); insert!("bundles", self.bundles.as_deref()); insert!("universe-size", self.universe); - insert!("universe", self.universe); + insert!("universe", self.universe); // PrimeAttributeName maps num_attributes → --universe insert!("biedges", self.biedges.as_deref()); insert!("left", self.left); insert!("right", self.right); @@ -1084,7 +1083,6 @@ impl CreateArgs { insert!("relations", self.relations.as_deref()); insert!("conjuncts-spec", self.conjuncts_spec.as_deref()); insert!("query-attribute", self.query); - insert!("query", self.query); insert!("rhs", self.rhs.as_deref()); insert!("required-columns", self.required_columns.as_deref()); insert!("num-groups", self.num_groups); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index b407ffa1..85b13712 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -89,7 +89,6 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.sink_2.is_none() && args.requirement_1.is_none() && args.requirement_2.is_none() - && args.requirement.is_none() && args.sizes.is_none() && args.probabilities.is_none() && args.capacity.is_none() @@ -136,8 +135,6 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.quantifiers.is_none() && args.usage.is_none() && args.storage.is_none() - && args.source.is_none() - && args.sink.is_none() && args.size_bound.is_none() && args.cut_bound.is_none() && args.values.is_none() @@ -147,8 +144,6 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.potential_edges.is_none() && args.budget.is_none() && args.max_cycle_length.is_none() - && args.deadlines.is_none() - && args.lengths.is_none() && args.precedence_pairs.is_none() && args.resource_bounds.is_none() && args.resource_requirements.is_none() @@ -172,17 +167,6 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.source_string.is_none() && args.target_string.is_none() && args.pointer_cost.is_none() - && args.capacities.is_none() - && args.source_1.is_none() - && args.sink_1.is_none() - && args.source_2.is_none() - && args.sink_2.is_none() - && args.requirement_1.is_none() - && args.requirement_2.is_none() - && args.requirement.is_none() - && args.homologous_pairs.is_none() - && args.num_attributes.is_none() - && args.dependencies.is_none() && args.relation_attrs.is_none() && args.known_keys.is_none() && args.num_objects.is_none() diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 51a3dec9..23390e6e 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -1870,7 +1870,7 @@ pub(super) fn help_flag_name(canonical: &str, field_name: &str) -> String { // General field-name overrides (previously in cli_flag_name) match field_name { "universe_size" => "universe-size".to_string(), - "collection" | "subsets" => "subsets".to_string(), + "collection" | "subsets" | "sets" => "subsets".to_string(), "left_size" => "left".to_string(), "right_size" => "right".to_string(), "edges" => "biedges".to_string(),