diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4227c2854..8303f0a42 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -151,7 +151,7 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense ### Key Patterns - `variant_params!` macro implements `Problem::variant()` — e.g., `crate::variant_params![G, W]` for two type params, `crate::variant_params![]` for none (see `src/variant.rs`) -- `declare_variants!` proc macro registers concrete type instantiations with best-known complexity and registry-backed load/serialize/value-solve/witness-solve metadata. One entry per problem may be marked `default`, and variable names in complexity strings are validated at compile time against actual getter methods. +- `declare_variants!` proc macro registers concrete type instantiations with best-known complexity and registry-backed load/serialize/value-solve/witness-solve metadata. One entry per problem may be marked `default`, and variable names in complexity strings are validated at compile time against actual getter methods. Ordinary models are constructed directly from their construction schema. When user-facing construction differs from persisted JSON, define a model-local `#[derive(CreateSpec)]` DTO plus `TryFrom`, use its generated `FIELDS` in `ProblemSchemaEntry`, and register it with `create LocalSpec`; never add model-name branches in CLI or MCP code. - `decision_problem_meta!` macro registers `DecisionProblemMeta` for a concrete inner type, providing the `DECISION_NAME` constant. - `register_decision_variant!` macro generates `declare_variants!`, `ProblemSchemaEntry`, and both `ReductionEntry` submissions (aggregate Decision→Opt + Turing Opt→Decision) for a `Decision

` variant. Callers must define inherent getters (`num_vertices()`, `num_edges()`, `k()`) on `Decision

` before invoking. Accepts `dims`, `fields`, and `size_getters` parameters for problem-specific size fields. - Problems parameterized by graph type `G` and optionally weight type `W` (problem-dependent) @@ -204,9 +204,9 @@ 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 and deferred:** `pred create` builds lightweight problem/variant command shells, then expands fields only for the selected command. Construction fields are classified as external, derived, or composite; both Clap and the builder consume that classification. Ordinary external fields come from `ProblemSchemaEntry`, derived fields are not exposed as flags, and composite graph fields expand through the existing construction semantics (for example `BipartiteGraph` uses `--left`, `--right`, and `--biedges`). New models do not add fields to a central Clap struct. Add parser support only when introducing a genuinely new field representation. -- **Each CLI input has one name.** Do not add compatibility aliases or multi-name lookup. Schema fields normally use `snake_case → kebab-case`; a composite or derived input may use the single construction name returned by `problem_help_flag_name()`. -- **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. Schema-driven CLI creation auto-restructures flat JSON into `{inner: {...}, bound}`. +- **CLI creation is registry-driven and deferred:** `pred create` expands flags only for the selected concrete variant. Ordinary models use `ProblemSchemaEntry.fields` directly. Models whose construction differs from persisted JSON own a typed `CreateSpec` and fallible conversion beside the model; CLI and MCP only normalize transport values and invoke the registered constructor. +- **Each construction input has one name and one concrete type per variant.** Do not add compatibility aliases or infer types from flag names. `CreateSpec` field names render as `snake_case → kebab-case` in CLI and remain `snake_case` in MCP. Add a reusable codec only for a genuinely new transport representation, never a model-name parser branch. +- **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. The generated construction spec accepts flat inner fields plus `bound`; persisted JSON remains `{inner: {...}, bound}`. - Aggregate-only models are first-class in `declare_variants!`; aggregate-only and Turing 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/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 371b747e5..4440019d0 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -68,15 +68,14 @@ Read these first to understand the patterns: - **Model tests:** `src/unit_tests/models/graph/maximum_independent_set.rs` - **Trait definitions / aggregate types:** `src/traits.rs` (`Problem`), `src/types.rs` (`Aggregate`, `Max`, `Min`, `Sum`, `Or`, `And`, `Extremum`) - **Registry dispatch boundary:** `src/registry/mod.rs`, `src/registry/variant.rs` -- **CLI aliases:** `problemreductions-cli/src/problem_name.rs` -- **CLI creation:** `problemreductions-cli/src/commands/create.rs` +- **CLI and MCP construction:** discovered from the model's registry entry; no frontend model-name dispatch - **Canonical model examples:** `src/example_db/model_builders.rs` ## Pre-review Checklist Before implementing, make sure the plan explicitly covers these items that structural review checks later: - Derive numeric implementation types from the mathematical domains in the issue and follow `docs/src/design.md#numeric-types-and-arithmetic`; serde/CLI construction uses the same validation as `new`/`try_new`, and boundary tests cover the supported maximum without requiring impractical allocation -- `ProblemSchemaEntry` metadata is complete for the current schema shape (`display_name`, `aliases`, `dimensions`, and constructor-facing `fields`) +- `ProblemSchemaEntry` metadata is complete for the construction interface (`display_name`, `aliases`, `dimensions`, and `fields`) - `Problem::Value` uses the correct aggregate wrapper and witness support is intentional - `declare_variants!` is present with exactly one `default` variant when multiple concrete variants exist - CLI discovery and `pred create ` support are included where applicable @@ -123,7 +122,7 @@ Create `src/models//.rs`: ``` Key decisions: -- **Schema metadata:** `ProblemSchemaEntry` must reflect the current registry schema shape, including `display_name`, `aliases`, `dimensions`, and constructor-facing `fields` +- **Schema metadata:** `ProblemSchemaEntry` must reflect the construction interface, including `display_name`, `aliases`, `dimensions`, and `fields` - **Objective problems:** use `type Value = Max<_>`, `Min<_>`, or `Extremum<_>` when the model should expose optimization-style witness helpers - **Witness problems:** use `type Value = Or` for existential feasibility problems - **Aggregate-only problems:** use a value-only aggregate such as `Sum<_>`, `And`, or a custom `Aggregate` when witnesses are not meaningful @@ -174,17 +173,19 @@ The CLI now loads, serializes, and brute-force solves problems through the core - Add a lowercase alias mapping in `resolve_alias()` (e.g., `"newproblem" => "NewProblem".to_string()`) - Only add short aliases to the `ALIASES` array if the abbreviation is **well-established in the literature** (e.g., MIS, MVC, SAT, TSP, CVP are standard; "KS" for Knapsack or "BP" for BinPacking are NOT — do not invent new abbreviations) -## Step 4.5: Add CLI creation support +## Step 4.5: Add construction support -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. +CLI and MCP construction are registry-driven. Do not edit either frontend to recognize a model name. -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. +1. If user-facing inputs exactly match persisted JSON fields, do nothing. The ordinary `declare_variants!` entry uses `ProblemSchemaEntry.fields` as required construction inputs and deserializes the model directly. -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`. +2. If construction has derived fields, renamed inputs, defaults depending on other inputs, or a composite value assembled from multiple inputs, define a model-local DTO with `#[derive(Deserialize, CreateSpec)]`. Its named fields are the complete public construction contract. Use `Option` only for genuinely optional inputs, doc comments for help text, and `#[create(codec = "...")]` when the transport syntax cannot be inferred from the Rust type. Set `ProblemSchemaEntry.fields` to `LocalCreateSpec::FIELDS` so the catalog and executable constructor share the derived metadata. -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. +3. Implement `TryFrom for Model`. Validate before calling constructors that assert or panic, return a descriptive error, compute derived state there, and build the canonical model value. -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). +4. Register the spec on each applicable variant: `default Model => "..." create LocalCreateSpec`. Both frontends then discover the inputs automatically and serialize the constructed typed model back to canonical persisted JSON. + +5. A new reusable external syntax may add one transport codec. It must dispatch by codec/type, never by canonical model name. Unknown or missing inputs are rejected by the core construction contract. ## Step 4.6: Add canonical model example to example_db @@ -314,9 +315,9 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Forgetting CLI alias | Must add lowercase entry in `problem_name.rs` `resolve_alias()` | | Adding a hand-written decision model | Use `Decision

` wrapper instead — see `decision_problem_meta!` + `register_decision_variant!` in `src/models/graph/minimum_vertex_cover.rs` for the pattern | | Inventing short aliases | Only use well-established literature abbreviations (MIS, SAT, TSP); do NOT invent new ones | -| 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`) | +| Adding frontend model-name branches | Construction is model-owned. Use a local `CreateSpec` and register it with `declare_variants!`; CLI and MCP must discover it. | +| Hand-maintaining custom construction fields twice | Derive `CreateSpec`, use `LocalCreateSpec::FIELDS` in `ProblemSchemaEntry`, and register the same type in `declare_variants!`. | +| Calling a panicking constructor from `TryFrom` | Validate the spec first and return a descriptive conversion error. | | 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 | | Claiming direct ILP solving but leaving ` -> ILP` for later | If the issue promises a direct ILP path, implement that rule in the same PR with exact overhead metadata and production-level ILP tests | diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index 5c302cc3e..be37a9085 100644 --- a/problemreductions-cli/Cargo.toml +++ b/problemreductions-cli/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" description = "CLI tool for exploring NP-hard problem reductions" license = "MIT" repository = "https://github.com/CodingThrust/problem-reductions" +default-run = "pred" [[bin]] name = "pred" diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 41e97fdd8..04fb1dbed 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -1,50 +1,31 @@ use crate::cli::{CreateArgs, ExampleSide}; use crate::dispatch::ProblemJsonOutput; use crate::output::OutputConfig; -use crate::problem_name::{ - resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error, -}; +use crate::problem_name::{resolve_problem_ref, unknown_problem_error}; use crate::util; use anyhow::{bail, Context, Result}; use num_bigint::BigUint; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; -use problemreductions::models::algebraic::{ - ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - SparseMatrixCompression, -}; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, LabelledArc, LabelledDigraph, LengthBoundedDisjointPaths, LongestCircuit, - MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumMaximalMatching, - RootedTreeArrangement, SteinerTree, SteinerTreeInGraphs, -}; -use problemreductions::models::misc::{ - CbqRelation, FrequencyTable, KnownValue, QueryArg, SchedulingWithIndividualDeadlines, - ThreePartition, + MinimumCutIntoBoundedSets, MinimumMaximalMatching, RootedTreeArrangement, SteinerTree, + SteinerTreeInGraphs, }; +use problemreductions::models::misc::{CbqRelation, FrequencyTable, KnownValue, QueryArg}; use problemreductions::models::Decision; use problemreductions::prelude::*; use problemreductions::topology::{ - BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, - TriangularSubgraph, UnitDiskGraph, + DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; 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::*; pub(crate) use self::schema_support::{create_inputs_for, InputValueKind}; -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"; - fn all_data_flags_empty(args: &CreateArgs) -> bool { args.is_empty() } @@ -276,37 +257,6 @@ fn resolve_rule_example( }) } -fn parse_precedence_pairs(raw: Option<&str>) -> Result> { - raw.filter(|s| !s.is_empty()) - .map(|s| { - s.split(',') - .map(|pair| { - let pair = pair.trim(); - let (pred, succ) = pair.split_once('>').ok_or_else(|| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v'", - pair - ) - })?; - let pred = pred.trim().parse::().map_err(|_| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", - pair - ) - })?; - let succ = succ.trim().parse::().map_err(|_| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", - pair - ) - })?; - Ok((pred, succ)) - }) - .collect() - }) - .unwrap_or_else(|| Ok(vec![])) -} - fn parse_job_shop_jobs(raw: &str) -> Result>> { let raw = raw.trim(); if raw.is_empty() { @@ -352,19 +302,6 @@ fn parse_job_shop_jobs(raw: &str) -> Result>> { .collect() } -fn validate_precedence_pairs(precedences: &[(usize, usize)], num_tasks: usize) -> Result<()> { - 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 - ); - } - Ok(()) -} - fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let example_spec = args .example @@ -413,9 +350,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let problem = args.problem.as_ref().ok_or_else(|| { anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") })?; - let resolved = resolve_catalog_problem_ref(problem)?; - let canonical = resolved.name(); - let resolved_variant = resolved.variant().clone(); + let (canonical, resolved_variant) = + crate::create_args::resolve_registered_create_variant(problem); if args.has("random") { return create_random(args, canonical, &resolved_variant, out); @@ -433,11 +369,6 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ); } - // Show schema-driven help when no data flags are provided - if all_data_flags_empty(args) { - bail!("No construction arguments were provided for {canonical}"); - } - let (data, variant) = create_schema_driven(args, canonical, &resolved_variant)?; let output = ProblemJsonOutput { @@ -449,23 +380,6 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { 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. /// Serialize a vertex-weight problem with a generic graph type. fn ser_vertex_weight_problem_with( @@ -519,260 +433,6 @@ 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.value::("left").ok_or_else(|| { - anyhow::anyhow!( - "{canonical} requires --left, --right, --biedges, and --k\n\nUsage: {usage}" - ) - })?; - let right = args.value::("right").ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --right (right partition size)\n\nUsage: {usage}") - })?; - let k = args.value::("k").ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --k ({k_description})\n\nUsage: {usage}") - })?; - let edges_str = args.raw("biedges").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 - .raw("graph") - .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.value::("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.value::("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.raw("positions").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.raw("positions").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.raw("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<&str>, - 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 - .raw("terminals") - .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 - .raw("terminal-pairs") - .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)"); @@ -780,11 +440,6 @@ fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> R 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.raw("edge-weights"), num_edges, "edge weight") -} - fn validate_vertex_index( label: &str, vertex: usize, @@ -798,161 +453,6 @@ fn validate_vertex_index( 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 - .raw("capacities") - .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.raw("lower-bounds").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.raw("bundle-capacities").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. -/// 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.raw("subsets"), "--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.raw("homologous-pairs").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" -/// 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)>> { @@ -986,214 +486,8 @@ fn parse_dependencies(input: &str) -> Result, Vec)>> { .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.raw("partition").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 - .raw("bundles") - .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.value::("threshold").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_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 - .raw("matrix") - .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_rows(rows_str: &str) -> Result>> { let matrix: Vec> = rows_str .split(';') @@ -1221,234 +515,6 @@ fn parse_bool_rows(rows_str: &str) -> Result>> { Ok(matrix) } -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 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 - .raw("matrix") - .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" -/// 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.raw("potential-weights").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 - .raw("budget") - .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 @@ -1496,92 +562,6 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } -fn parse_prescribed_paths( - args: &CreateArgs, - num_arcs: usize, - usage: &str, -) -> Result>> { - let paths_str = args - .raw("paths") - .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 - .raw("arcs") - .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.raw("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 StackerCrane `--arc-lengths` as per-arc costs, defaulting to all 1s. -fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { - match &args.raw("arc-lengths") { - 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. pub(super) fn supports_random(name: &str) -> bool { matches!( diff --git a/problemreductions-cli/src/commands/create/schema_semantics.rs b/problemreductions-cli/src/commands/create/schema_semantics.rs deleted file mode 100644 index 6554978f4..000000000 --- a/problemreductions-cli/src/commands/create/schema_semantics.rs +++ /dev/null @@ -1,1321 +0,0 @@ -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.raw("weights").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.value::("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.value::("max-weight").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 \"1,3,6;2,4,7;1,2,5\" --delay \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; - let capacities_str = args.raw("capacities").ok_or_else(|| { - anyhow::anyhow!( - "CapacityAssignment requires --capacities, --cost, --delay, and --delay-budget\n\n{usage}" - ) - })?; - let cost_matrix_str = args - .raw("cost") - .ok_or_else(|| anyhow::anyhow!("CapacityAssignment requires --cost\n\n{usage}"))?; - let delay_matrix_str = args - .raw("delay") - .ok_or_else(|| anyhow::anyhow!("CapacityAssignment requires --delay\n\n{usage}"))?; - let _ = args.value::("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.value::("n").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --n, --subsets, and --target\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let sets_str = args.raw("subsets").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --subsets (functional deps as lhs:rhs;...)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let target_str = args.raw("target").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"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.raw("basis").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 - .raw("target-vec") - .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.raw("bounds"), - &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.value::("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.raw("matrix").ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" - ) - })?; - let bound = args.value::("bound-k").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.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "ComparativeContainment requires --universe-size, --r-sets, and --s-sets\n\n\ - Usage: pred create 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]" - ) - })?; - let r_sets = parse_named_sets(args.raw("r-sets"), "--r-sets")?; - let s_sets = parse_named_sets(args.raw("s-sets"), "--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.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - let s_weights = parse_named_set_weights( - args.raw("s-weights"), - 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.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights_f64( - args.raw("s-weights"), - 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.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights( - args.raw("s-weights"), - 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.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "ExactCoverBy3Sets requires --universe-size and --subsets\n\n\ - Usage: pred create X3C --universe-size 6 --subsets \"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 - .value::("source") - .ok_or_else(|| anyhow::anyhow!("GeneralizedHex requires --source\n\n{usage}"))?; - let sink = args - .value::("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.raw("string").ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound( - args.value::("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.value::("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 - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("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.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") - })?; - let sink = args - .value::("sink") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; - let _ = args.value::("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.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(s) = args.raw("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.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") - })?; - let _ = args.value::("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.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities_str = args.raw("capacities").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.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") - })?; - let sink = args.value::("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.raw("multipliers").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.value::("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 - .raw("jobs") - .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 = args - .value::("num-processors") - .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.value::("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.value::("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.value::("k"), - None, - "KthBestSpanningTree", - ) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args - .value::("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.value::("source").ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") - })?; - let bound = args.value::("max-length").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.raw("strings").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 - .value::("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.raw("weights").is_some() { - bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); - } - let edge_lengths_raw = args.raw("edge-lengths").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.value::("source-vertex").ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") - })?; - let target_vertex = args.value::("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.value::("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, - )?; - } - "MaximumCoKPlex" => { - let usage = "Usage: pred create MaximumCoKPlex/i32 --graph 0-1,1-2,2-3,3-4,4-0 --weights 5,1,4,1,3 --k 2"; - let (_, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let weights = parse_vertex_weights(args, num_vertices)?; - let graph_type = resolved_graph_type(resolved_variant); - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - let k = args - .value::("k") - .ok_or_else(|| anyhow::anyhow!("MaximumCoKPlex requires --k\n\n{usage}"))?; - if k == 0 { - bail!("MaximumCoKPlex: --k must be at least 1\n\n{usage}"); - } - } - "MinimumHittingSet" => { - let universe = args.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "MinimumHittingSet requires --universe-size and --subsets\n\n\ - Usage: pred create MinimumHittingSet --universe-size 6 --subsets \"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.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("MinimumDummyActivitiesPert requires --arcs\n\n{usage}") - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.value::("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.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") - })?; - let (_, num_arcs) = - parse_directed_graph(arcs_str, args.value::("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.raw("usage"), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - let _ = parse_vertex_i64_values( - args.raw("storage"), - "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.raw("lengths").ok_or_else(|| { - anyhow::anyhow!( - "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" - ) - })?; - let num_processors = args.value::("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.value::("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 - .raw("budget") - .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.value::("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.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(s) = args.raw("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.value::("source").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") - })?; - let _ = args.value::("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.value::("num-periods").ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") - })?; - let demands = parse_named_u64_list( - args.raw("demands"), - "ProductionPlanning", - "--demands", - usage, - )?; - let capacities = parse_named_u64_list( - args.raw("capacities"), - "ProductionPlanning", - "--capacities", - usage, - )?; - let setup_costs = parse_named_u64_list( - args.raw("setup-costs"), - "ProductionPlanning", - "--setup-costs", - usage, - )?; - let production_costs = parse_named_u64_list( - args.raw("production-costs"), - "ProductionPlanning", - "--production-costs", - usage, - )?; - let inventory_costs = parse_named_u64_list( - args.raw("inventory-costs"), - "ProductionPlanning", - "--inventory-costs", - usage, - )?; - let _ = args.value::("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 [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; - let deadlines_str = args.raw("deadlines").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and --num-processors\n\n{usage}" - ) - })?; - let num_tasks = args.value::("num-tasks").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" - ) - })?; - let num_processors = args.value::("num-processors").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-processors\n\n{usage}" - ) - })?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = parse_precedence_pairs(args.raw("precedences"))?; - 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.raw("source-string").ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") - })?; - let target_str = args.raw("target-string").ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") - })?; - let _ = parse_nonnegative_usize_bound( - args.value::("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.value::("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.value::("bound-k").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 - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; - let (arcs_graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("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.raw("edge-lengths"), - 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.raw("sizes").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.value::("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.value::("source").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") - })?; - let requirement = args.value::("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.raw("costs").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.raw("precedences"))?; - validate_precedence_pairs(&precedences, costs.len())?; - } - "SequencingToMinimizeWeightedTardiness" => { - let lengths_str = args.raw("lengths").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.raw("weights").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.raw("deadlines").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.value::("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.raw("release-times").ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") - })?; - let dl_str = args.raw("deadlines").ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") - })?; - let len_str = args.raw("lengths").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.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --universe-size, --subsets, and --k\n\n\ - Usage: pred create SetBasis --universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let _ = args.value::("k").ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --k\n\n\ - Usage: pred create SetBasis --universe-size 4 --subsets \"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.raw("weights").is_some() { - bail!( - "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" - ); - } - let edge_lengths_raw = args.raw("edge-lengths").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" - ) - })?; - let edge_weights_raw = args.raw("edge-weights").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.value::("source-vertex").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" - ) - })?; - let target_vertex = args.value::("target-vertex").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" - ) - })?; - let weight_bound = args.value::("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.value::("num-periods").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") - })?; - let num_craftsmen = args.value::("num-craftsmen").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") - })?; - let num_tasks = args.value::("num-tasks").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") - })?; - let craftsman_avail = - parse_named_bool_rows(args.raw("craftsman-avail"), "--craftsman-avail", usage)?; - let task_avail = parse_named_bool_rows(args.raw("task-avail"), "--task-avail", usage)?; - let requirements = parse_timetable_requirements(args.raw("requirements"), 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.value::("source-1").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") - })?; - let sink_1 = args.value::("sink-1").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") - })?; - let source_2 = args.value::("source-2").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") - })?; - let sink_2 = args.value::("sink-2").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") - })?; - let _ = args.value::("requirement-1").ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" - ) - })?; - let _ = args.value::("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 index 2a6d95700..bf2c8e896 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -25,20 +25,7 @@ pub(crate) struct CreateInput { pub kind: InputValueKind, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FieldConstructionMode { - External, - Derived, - MixedGraph, - BipartiteGraph, -} - 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" { @@ -122,6 +109,13 @@ pub(super) fn create_schema_driven( }, )?; + if let Some(inputs) = variant_entry.create_inputs { + let data = normalize_registered_create_inputs(args, inputs, resolved_variant) + .map_err(|error| with_registered_usage(error, canonical, inputs))?; + return construct_canonical(canonical, resolved_variant, data) + .map_err(|error| with_registered_usage(error, canonical, inputs)); + } + let graph_type = resolved_graph_type(resolved_variant); let is_geometry = matches!( graph_type, @@ -129,109 +123,151 @@ pub(super) fn create_schema_driven( ); 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_name = problem_help_flag_name(canonical, field.name, field.type_name, is_geometry); - let raw_value = args.raw(&flag_name); - let construction_mode = field_construction_mode(canonical, field.name, &concrete_type); - let value = if construction_mode == FieldConstructionMode::Derived { - derive_schema_field_value(args, canonical, field.name, &concrete_type, &context)? - .ok_or_else(|| { - anyhow::anyhow!("No construction rule derives {canonical}.{}", field.name) - })? - } else if construction_mode == FieldConstructionMode::External { - 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 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( + let flag_name = problem_help_flag_name(field.name, field.type_name, is_geometry); + let raw_value = args.raw(&flag_name).ok_or_else(|| { + with_schema_usage( missing_schema_field_error(canonical, field.name, field.type_name, is_geometry), canonical, resolved_variant, - )); - }; + ) + })?; + let value = parse_schema_field_value(&concrete_type, field.name, raw_value, &context) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; context.remember(field.name, &concrete_type, &value); json_map.insert(field.name.to_string(), value); } - // KColoring/KN stores the number of colors at runtime in `num_colors`. - // The schema only declares `graph`, so inject `num_colors` from --k for KN. - if canonical == "KColoring" && resolved_variant.get("k").map(|s| s.as_str()) == Some("KN") { - if let Some(k) = args.value::("k") { - json_map.insert("num_colors".to_string(), serde_json::json!(k)); + let data = serde_json::Value::Object(json_map); + construct_canonical(canonical, resolved_variant, data) +} + +fn construct_canonical( + canonical: &str, + resolved_variant: &BTreeMap, + data: serde_json::Value, +) -> Result<(serde_json::Value, BTreeMap)> { + let problem = problemreductions::registry::construct_dyn(canonical, resolved_variant, data)?; + let constructed_variant = problem.variant_map(); + anyhow::ensure!( + problem.problem_name() == canonical && constructed_variant == *resolved_variant, + "registered constructor for {canonical} {resolved_variant:?} returned {} {constructed_variant:?}", + problem.problem_name(), + ); + Ok((problem.serialize_json(), constructed_variant)) +} + +fn normalize_registered_create_inputs( + args: &CreateArgs, + inputs: &[problemreductions::registry::CreateInputInfo], + resolved_variant: &BTreeMap, +) -> Result { + let mut values = serde_json::Map::new(); + for input in inputs { + let flag_name = input.name.replace('_', "-"); + if let Some(raw) = args.raw(&flag_name) { + let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); + values.insert( + input.name.to_string(), + normalize_registered_input(input, &concrete_type, raw)?, + ); } } + Ok(serde_json::Value::Object(values)) +} - // Decision

types serialize as {inner: {graph, weights, ...}, bound} but schema - // fields are flat (graph, weights, bound). Restructure when the canonical name - // indicates a Decision wrapper. - let data = if canonical.starts_with("Decision") { - let bound = json_map - .remove("bound") - .expect("Decision types require a bound field"); - let mut outer = serde_json::Map::new(); - outer.insert("inner".to_string(), serde_json::Value::Object(json_map)); - outer.insert("bound".to_string(), bound); - serde_json::Value::Object(outer) - } else { - 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( +fn normalize_registered_input( + input: &problemreductions::registry::CreateInputInfo, + concrete_type: &str, + raw: &str, +) -> Result { + use problemreductions::registry::CreateInputCodec; + + let value = match input.codec { + CreateInputCodec::Json => serde_json::from_str(raw).map_err(|error| { anyhow::anyhow!( - "Schema-driven factory rejected generated data for {canonical}: {error}" - ), - canonical, - resolved_variant, - ) - })?; + "Invalid JSON for --{}: {error}", + input.name.replace('_', "-") + ) + })?, + CreateInputCodec::EdgeList | CreateInputCodec::BipartiteEdgeList => { + serde_json::to_value(util::parse_edge_pairs(raw)?)? + } + CreateInputCodec::ArcList => serde_json::to_value(parse_registered_arcs(raw)?)?, + CreateInputCodec::EqualityPairList => { + serde_json::to_value(parse_registered_equality_pairs(raw)?)? + } + CreateInputCodec::FunctionalDependencyList => { + serde_json::to_value(parse_registered_functional_dependencies(raw)?)? + } + CreateInputCodec::CharacterRows => { + serde_json::to_value(parse_registered_character_rows(raw))? + } + CreateInputCodec::Auto + | CreateInputCodec::Scalar + | CreateInputCodec::CommaSeparated + | CreateInputCodec::SemicolonSeparated => { + parse_field_value(concrete_type, input.name, raw, &CreateContext::default())? + } + }; + Ok(value) +} - Ok((data, resolved_variant.clone())) +fn parse_registered_character_rows(raw: &str) -> Vec> { + let mut alphabet = BTreeMap::new(); + raw.split(';') + .map(|row| { + row.chars() + .map(|symbol| { + let next = alphabet.len(); + *alphabet.entry(symbol).or_insert(next) + }) + .collect() + }) + .collect() +} + +fn parse_registered_arcs(raw: &str) -> Result> { + raw.split(',') + .map(|arc| { + let (source, target) = arc.trim().split_once('>').ok_or_else(|| { + anyhow::anyhow!("Invalid arc '{}': expected format u>v", arc.trim()) + })?; + Ok((source.trim().parse()?, target.trim().parse()?)) + }) + .collect() +} + +fn parse_registered_equality_pairs(raw: &str) -> Result> { + raw.split(';') + .map(|pair| { + let (left, right) = pair.trim().split_once('=').ok_or_else(|| { + anyhow::anyhow!("Invalid pair '{}': expected format left=right", pair.trim()) + })?; + Ok((left.trim().parse()?, right.trim().parse()?)) + }) + .collect() +} + +fn parse_registered_functional_dependencies(raw: &str) -> Result, Vec)>> { + raw.split(';') + .map(|dependency| { + let (left, right) = dependency.trim().split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid functional dependency '{}': expected format lhs:rhs", + dependency.trim() + ) + })?; + Ok(( + util::parse_comma_list(left)?, + util::parse_comma_list(right)?, + )) + }) + .collect() } pub(super) fn missing_schema_field_error( @@ -240,201 +276,57 @@ pub(super) fn missing_schema_field_error( field_type: &str, is_geometry: bool, ) -> anyhow::Error { - let flag = problem_help_flag_name(canonical, field_name, field_type, is_geometry); + let flag = problem_help_flag_name(field_name, field_type, is_geometry); let requirement = format!("--{flag}"); 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.value::("n").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --n, --subsets, 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.value::("max-weight").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 - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (_, num_arcs) = parse_directed_graph(arcs_str, args.value::("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.value::("max-length").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.raw("usage"), - "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.raw("storage"), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) - } - ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => Ok(serde_json::to_value( - parse_precedence_pairs(args.raw("precedences"))?, - )?), - ("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), - } + parse_field_value(concrete_type, field_name, raw, context) } pub(crate) fn create_inputs_for( canonical: &str, resolved_variant: &BTreeMap, ) -> Vec { - let schema = problemreductions::registry::find_problem_type(canonical) - .unwrap_or_else(|| panic!("missing schema for `{canonical}`")); - let graph_type = resolved_graph_type(resolved_variant); - let is_geometry = matches!( - graph_type, - "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" - ); + let variant_entry = + problemreductions::registry::find_variant_entry(canonical, resolved_variant) + .unwrap_or_else(|| { + panic!("missing registered variant for `{canonical}` with {resolved_variant:?}") + }); let mut inputs = BTreeMap::::new(); - for field in schema.fields { - let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); - match field_construction_mode(canonical, field.name, &concrete_type) { - FieldConstructionMode::Derived => continue, - FieldConstructionMode::MixedGraph => { - insert_create_input(&mut inputs, "graph", InputValueKind::Text, field.name); - insert_create_input(&mut inputs, "arcs", InputValueKind::Text, field.name); - } - FieldConstructionMode::BipartiteGraph => { - for (name, kind) in [ - ("left", InputValueKind::Usize), - ("right", InputValueKind::Usize), - ("biedges", InputValueKind::Text), - ] { - insert_create_input(&mut inputs, name, kind, field.name); - } - } - FieldConstructionMode::External => match concrete_type.as_str() { + if let Some(custom_inputs) = variant_entry.create_inputs { + for input in custom_inputs { + let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); + insert_create_input( + &mut inputs, + &input.name.replace('_', "-"), + input_value_kind(&concrete_type), + input.name, + ); + } + } else { + let schema = problemreductions::registry::find_problem_type(canonical) + .unwrap_or_else(|| panic!("missing schema for `{canonical}`")); + let graph_type = resolved_graph_type(resolved_variant); + let is_geometry = matches!( + graph_type, + "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" + ); + for field in schema.fields { + let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); + match concrete_type.as_str() { "DirectedGraph" => { insert_create_input(&mut inputs, "arcs", InputValueKind::Text, field.name); } _ => { - let name = - problem_help_flag_name(canonical, field.name, field.type_name, is_geometry); + let name = problem_help_flag_name(field.name, field.type_name, is_geometry); insert_create_input( &mut inputs, &name, @@ -442,47 +334,27 @@ pub(crate) fn create_inputs_for( field.name, ); } - }, + } + } + if schema.fields.iter().any(|field| { + let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); + matches!(concrete_type.as_str(), "SimpleGraph" | "DirectedGraph") + }) { + insert_create_input( + &mut inputs, + "num-vertices", + InputValueKind::Usize, + "graph vertex count", + ); + } + if graph_type == "UnitDiskGraph" { + insert_create_input( + &mut inputs, + "radius", + InputValueKind::F64, + "unit-disk graph radius", + ); } - } - - if schema.fields.iter().any(|field| { - let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); - matches!( - concrete_type.as_str(), - "SimpleGraph" | "DirectedGraph" | "MixedGraph" - ) - }) { - insert_create_input( - &mut inputs, - "num-vertices", - InputValueKind::Usize, - "graph vertex count", - ); - } - if graph_type == "UnitDiskGraph" { - insert_create_input( - &mut inputs, - "radius", - InputValueKind::F64, - "unit-disk graph radius", - ); - } - if canonical == "GraphPartitioning" { - insert_create_input( - &mut inputs, - "num-partitions", - InputValueKind::Usize, - "partition count", - ); - } - if canonical == "KColoring" && resolved_variant.get("k").map(String::as_str) == Some("KN") { - insert_create_input( - &mut inputs, - "k", - InputValueKind::Usize, - "runtime color count", - ); } if super::supports_random(canonical) { for (name, kind) in [ @@ -532,31 +404,6 @@ fn input_value_kind(concrete_type: &str) -> InputValueKind { } } -fn field_construction_mode( - canonical: &str, - field_name: &str, - concrete_type: &str, -) -> FieldConstructionMode { - match normalize_type_name(concrete_type).as_str() { - "MixedGraph" => return FieldConstructionMode::MixedGraph, - "BipartiteGraph" => return FieldConstructionMode::BipartiteGraph, - "One" => return FieldConstructionMode::Derived, - _ => {} - } - if matches!( - (canonical, field_name), - ("ConjunctiveBooleanQuery", "num_variables") - | ("LongestCommonSubsequence", "max_length") - | ("ShortestCommonSupersequence", "max_length") - | ("QUBO", "num_vars") - | ("LengthBoundedDisjointPaths", "max_paths") - ) { - FieldConstructionMode::Derived - } else { - FieldConstructionMode::External - } -} - pub(super) fn resolve_schema_field_type( type_name: &str, resolved_variant: &BTreeMap, @@ -605,272 +452,6 @@ pub(super) fn seed_schema_context_from_cli( 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 - .value::("left") - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; - let right = args - .value::("right") - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; - let edges_raw = args - .raw("biedges") - .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.raw("bounds"), context)?)); - } - - if canonical == "ConjunctiveBooleanQuery" - && field_name == "num_variables" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .raw("conjuncts") - .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts"))?; - 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 - .raw("string") - .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 - .value::("alphabet-size") - .unwrap_or(inferred)))); - } - - if canonical == "JobShopScheduling" - && field_name == "num_processors" - && normalize_type_name(concrete_type) == "usize" - { - let inferred_processors = match args.raw("jobs") { - 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 = args - .value::("num-processors") - .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 matches!( - canonical, - "LongestCommonSubsequence" | "ShortestCommonSupersequence" - ) && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .raw("strings") - .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; - let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; - return Ok(Some(serde_json::json!(args - .value::("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 == "ShortestCommonSupersequence" - && 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!("SCS max_length derivation requires parsed strings"), - )?)?; - let max_length = strings.iter().map(Vec::len).sum::(); - 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.raw("source-string").unwrap_or(""))?; - let target = parse_symbol_list_allow_empty(args.raw("target-string").unwrap_or(""))?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .value::("alphabet-size") - .unwrap_or(inferred)))); - } - - if field_name == "precedences" - && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" - && args.raw("precedences").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.raw("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); - if normalized == "One" { - return Ok(Some(serde_json::json!(1))); - } - - 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 with_schema_usage( error: anyhow::Error, canonical: &str, @@ -880,11 +461,38 @@ pub(super) fn with_schema_usage( 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) - ) + let flags = create_inputs_for(canonical, resolved_variant) + .into_iter() + .map(|input| { + if input.kind == InputValueKind::Bool { + format!("[--{}]", input.name) + } else { + format!("--{} ", input.name) + } + }) + .collect::>() + .join(" "); + anyhow::anyhow!("{message}\n\nUsage: pred create {canonical} {flags}",) +} + +fn with_registered_usage( + error: anyhow::Error, + canonical: &str, + inputs: &[problemreductions::registry::CreateInputInfo], +) -> anyhow::Error { + let flags = inputs + .iter() + .map(|input| { + let flag = format!("--{} ", input.name.replace('_', "-")); + if input.required { + flag + } else { + format!("[{flag}]") + } + }) + .collect::>() + .join(" "); + anyhow::anyhow!("{error}\n\nUsage: pred create {canonical} {flags}") } pub(super) fn parse_field_value( @@ -935,6 +543,7 @@ pub(super) fn parse_field_value( "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<(i32,i32)>" => serde_json::to_value(util::parse_positions::(raw, "0,0")?)?, "(f64,f64)" => parse_f64_pair_value(raw)?, "Vec>" => parse_nested_pair_list_value(raw)?, "Vec" => { @@ -1126,31 +735,6 @@ pub(super) fn parse_nested_pair_list_value(raw: &str) -> Result 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") @@ -1378,91 +962,6 @@ pub(super) fn parse_string_list_value(raw: &str) -> Result { 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, @@ -1713,442 +1212,8 @@ pub(super) fn parse_unit_disk_graph_value( Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) } -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", - }, - "DecisionMinimumVertexCover" => match graph_type { - Some("KingsSubgraph") => { - "--positions \"0,0;1,0;1,1;0,1\" --weights 1,1,1,1 --bound 2" - } - Some("TriangularSubgraph") => { - "--positions \"0,0;0,1;1,0;1,1\" --weights 1,1,1,1 --bound 2" - } - Some("UnitDiskGraph") => { - "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5 --weights 1,1,1 --bound 2" - } - _ => "--graph 0-1,1-2,0-2,2-3 --weights 1,1,1,1 --bound 2", - }, - "KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3", - "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 \"1,3,6;2,4,7;1,2,5\" --delay \"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" - } - "MinimumCostMaximumFlow" => { - "--arcs \"0>1,0>2,1>2,1>3,2>3\" --capacities 2,1,1,1,2 --costs 1,0,0,1,2 --source 0 --sink 3" - } - "MinimumCostCirculation" => { - "--arcs \"0>1,1>0,0>2,2>0\" --capacities 2,2,1,1 --costs 2,-3,1,-4" - } - "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" - } - "EulerianPath" => "--arcs \"0>1,0>1,1>2,2>0\" --num-vertices 3", - "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 --subsets \"0,1,2;2,3,4;0,2,4;1,3,4\"", - "CyclicOrdering" => "--n 5 --subsets \"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" => "--subsets \"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 --subsets \"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" - } - "ClosestString" => { - "--alphabet-size 2 --strings \"0,0,0;0,1,1;1,0,1;1,1,0\"" - } - "ClosestSubstring" => { - "--alphabet-size 2 --strings \"0,0,0,1,1;1,0,1,0,0;1,1,0,0,1\" --substring-length 3" - } - "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-size 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 \"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 --precedences \"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 "subsets".to_string(), - ("BoyceCoddNormalFormViolation", "target_subset") => return "target".to_string(), - ("CapacityAssignment", "cost") => return "cost".to_string(), - ("CapacityAssignment", "delay") => return "delay".to_string(), - ("FlowShopScheduling", "num_processors") - | ("JobShopScheduling", "num_processors") - | ("OpenShopScheduling", "num_machines") - | ("SchedulingWithIndividualDeadlines", "num_processors") => { - return "num-processors".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-size".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".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(), - ("MaximumCoKPlex", "bound_k") => return "k".to_string(), - ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), - ("BicliqueCover", "left_size") => return "left".to_string(), - ("BicliqueCover", "right_size") => return "right".to_string(), - ("BicliqueCover", "edges") => return "biedges".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" | "sets" | "subsets" => "subsets".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.raw("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_name(field_name: &str) -> String { + field_name.replace("_", "-") } pub(super) fn parse_nonnegative_usize_bound( @@ -2160,111 +1225,18 @@ pub(super) fn parse_nonnegative_usize_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 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 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), - }; + "positions".to_string() + } else if field_type == "DirectedGraph" { + "arcs".to_string() + } else { + help_flag_name(field_name) } - help_flag_name(canonical, field_name) } pub(super) fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 98e86a13b..70e5d7834 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -19,34 +19,6 @@ fn temp_output_path(name: &str) -> PathBuf { 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_uses_num_processors_for_scheduling() { - assert_eq!( - help_flag_name("SchedulingWithIndividualDeadlines", "num_processors"), - "num-processors" - ); - assert_eq!( - help_flag_name("FlowShopScheduling", "num_processors"), - "num-processors" - ); -} - #[test] fn test_parse_field_value_parses_simple_graph_to_json() { let value = parse_field_value("SimpleGraph", "graph", "0-1,1-2", &CreateContext::default()) @@ -90,15 +62,6 @@ fn test_parse_field_value_parses_job_shop_jobs() { ); } -#[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_create_schema_driven_builds_job_shop_scheduling() { let cli = Cli::parse_from([ @@ -125,6 +88,140 @@ fn test_create_schema_driven_builds_job_shop_scheduling() { assert_eq!(data["jobs"][0], serde_json::json!([[0, 3], [1, 4]])); } +#[test] +fn construction_contract_scs_uses_registered_spec_and_canonical_serialization() { + let cli = Cli::parse_from(["pred", "create", "SCS", "--strings", "0,1;1,2"]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = + create_schema_driven(&args, "ShortestCommonSupersequence", &BTreeMap::new()) + .expect("registered SCS constructor should succeed"); + + assert!(variant.is_empty()); + assert_eq!(data["alphabet_size"], 3); + assert_eq!(data["max_length"], 4); + assert_eq!(data["strings"], serde_json::json!([[0, 1], [1, 2]])); +} + +#[test] +fn construction_contract_cli_discovers_test_only_registered_model() { + let cli = Cli::parse_from([ + "pred", + "create", + crate::test_support::AGGREGATE_SOURCE_NAME, + "--values", + "2,5,7", + ]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven( + &args, + crate::test_support::AGGREGATE_SOURCE_NAME, + &BTreeMap::new(), + ) + .expect("test-only registry model should be constructed without frontend dispatch"); + + assert!(variant.is_empty()); + assert_eq!(data, serde_json::json!({"values": [2, 5, 7]})); +} + +#[test] +fn construction_contract_preserves_variant_declared_numeric_types() { + let max_u64 = u64::MAX.to_string(); + let cli = Cli::parse_from([ + "pred", + "create", + "ThreePartition", + "--sizes", + "6148914691236517205,6148914691236517205,6148914691236517205", + "--bound", + max_u64.as_str(), + ]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + let (data, _) = create_schema_driven(&args, "ThreePartition", &BTreeMap::new()).unwrap(); + assert_eq!(data["bound"], serde_json::json!(u64::MAX)); + + let huge = "340282366920938463463374607431768211457"; + let cli = Cli::parse_from([ + "pred", + "create", + "SubsetSum", + "--sizes", + huge, + "--target", + huge, + ]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + let (data, _) = create_schema_driven(&args, "SubsetSum", &BTreeMap::new()).unwrap(); + assert_eq!(data["target"], serde_json::json!(huge)); +} + +#[test] +fn construction_contract_biclique_uses_registered_composite_inputs() { + let cli = Cli::parse_from([ + "pred", + "create", + "BicliqueCover", + "--left", + "2", + "--right", + "3", + "--biedges", + "0-0,0-1,1-2", + "--k", + "2", + ]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let (data, variant) = create_schema_driven(&args, "BicliqueCover", &BTreeMap::new()) + .expect("registered BicliqueCover constructor should succeed"); + + assert!(variant.is_empty()); + assert_eq!(data["graph"]["left_size"], 2); + assert_eq!(data["graph"]["right_size"], 3); + assert_eq!( + data["graph"]["edges"], + serde_json::json!([[0, 0], [0, 1], [1, 2]]) + ); + assert_eq!(data["k"], 2); +} + +#[test] +fn construction_contract_biclique_missing_input_comes_from_core_contract() { + let cli = Cli::parse_from([ + "pred", + "create", + "BicliqueCover", + "--left", + "2", + "--right", + "3", + "--k", + "2", + ]); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let error = create_schema_driven(&args, "BicliqueCover", &BTreeMap::new()) + .expect_err("missing biedges must be rejected"); + assert_eq!( + error.to_string(), + "missing required construction input(s): biedges\n\n\ +Usage: pred create BicliqueCover --left --right --biedges --k " + ); +} + #[test] fn test_create_schema_driven_builds_quantified_boolean_formulas() { let cli = Cli::parse_from([ @@ -203,9 +300,9 @@ fn test_create_schema_driven_builds_conjunctive_boolean_query() { "--domain-size", "6", "--relations", - "2:0,3|1,3;3:0,1,5|1,2,5", + r#"[{"arity":2,"tuples":[[0,3],[1,3]]},{"arity":3,"tuples":[[0,1,5],[1,2,5]]}]"#, "--conjuncts", - "0:v0,c3;0:v1,c3;1:v0,v1,c5", + r#"[[0,[{"Variable":0},{"Constant":3}]],[0,[{"Variable":1},{"Constant":3}]],[1,[{"Variable":0},{"Variable":1},{"Constant":5}]]]"#, ]); let Commands::Create(args) = cli.command else { @@ -271,9 +368,9 @@ fn test_create_schema_driven_builds_cdft() { "--attribute-domains", "2,3,2", "--frequency-tables", - "0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1", + r#"[{"attribute_a":0,"attribute_b":1,"counts":[[1,1,1],[1,1,1]]},{"attribute_a":1,"attribute_b":2,"counts":[[1,1],[0,2],[1,1]]}]"#, "--known-values", - "0,0,0;3,0,1;1,2,1", + r#"[{"object":0,"attribute":0,"value":0},{"object":3,"attribute":0,"value":1},{"object":1,"attribute":2,"value":1}]"#, ]); let Commands::Create(args) = cli.command else { @@ -393,35 +490,6 @@ fn test_create_schema_driven_builds_unit_disk_graph_problem_with_default_radius( ); } -#[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") @@ -511,55 +579,6 @@ fn test_create_prime_attribute_name_accepts_canonical_flags() { ); } -#[test] -fn test_problem_help_uses_prime_attribute_name_cli_overrides() { - assert_eq!( - problem_help_flag_name("PrimeAttributeName", "num_attributes", "usize", false), - "universe-size" - ); - 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_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_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(); @@ -739,28 +758,6 @@ fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() { ); } -#[test] -fn test_problem_help_uses_num_tasks_for_timetable_design() { - assert_eq!( - problem_help_flag_name("TimetableDesign", "num_tasks", "usize", false), - "num-tasks" - ); -} - -#[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([ @@ -819,7 +816,7 @@ fn test_create_three_partition_requires_bound() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("ThreePartition requires --bound")); + assert!(err.contains("missing required construction input(s): bound")); } #[test] @@ -970,7 +967,6 @@ fn test_create_timetable_design_reports_invalid_matrix_without_panic() { err.contains("--craftsman-avail"), "expected timetable matrix validation error, got: {err}" ); - assert!(err.contains("Usage: pred create TimetableDesign")); } #[test] @@ -1036,7 +1032,9 @@ fn test_create_generalized_hex_requires_sink() { }; let err = create(&args, &out).unwrap_err(); - assert!(err.to_string().contains("GeneralizedHex requires --sink")); + assert!(err + .to_string() + .contains("missing required construction input(s): sink")); } #[test] @@ -1179,7 +1177,7 @@ fn test_create_production_planning_requires_all_period_vectors() { let err = create(&args, &out).unwrap_err(); assert!(err .to_string() - .contains("ProductionPlanning requires --production-costs")); + .contains("missing required construction input(s): production_costs")); } #[test] @@ -1218,7 +1216,7 @@ fn test_create_production_planning_rejects_mismatched_period_lengths() { let err = create(&args, &out).unwrap_err(); assert!(err .to_string() - .contains("--demands must contain exactly 6 entries")); + .contains("demands has 5 entries, expected 6")); } #[test] @@ -1442,7 +1440,7 @@ fn test_create_longest_path_requires_edge_lengths() { let err = create(&args, &out).unwrap_err(); assert!(err .to_string() - .contains("LongestPath requires --edge-lengths")); + .contains("missing required construction input(s): edge_lengths")); } #[test] @@ -1499,7 +1497,7 @@ fn test_create_undirected_flow_lower_bounds_requires_lower_bounds() { let err = create(&args, &out).unwrap_err(); assert!(err .to_string() - .contains("UndirectedFlowLowerBounds requires --lower-bounds")); + .contains("missing required construction input(s): lower_bounds")); } fn empty_args() -> CreateArgs { @@ -1540,34 +1538,6 @@ fn test_all_data_flags_empty_treats_job_tasks_as_input() { assert!(!all_data_flags_empty(&args)); } -#[test] -fn test_parse_potential_edges() { - let mut args = empty_args(); - args.insert("potential-weights", "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.insert("potential-weights", "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.insert("budget", "7".to_string()); - - assert_eq!(parse_budget(&args).unwrap(), 7); -} - #[test] fn test_create_disjoint_connecting_paths_json() { use crate::dispatch::ProblemJsonOutput; @@ -1623,55 +1593,6 @@ fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { assert!(err.contains("pairwise disjoint")); } -#[test] -fn test_parse_homologous_pairs() { - let mut args = empty_args(); - args.insert("homologous-pairs", "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.insert("homologous-pairs", "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.insert("graph", "0-1".to_string()); - args.insert("num-vertices", 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(); @@ -1782,7 +1703,7 @@ fn test_create_partial_feedback_edge_set_requires_max_cycle_length() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("PartialFeedbackEdgeSet requires --max-cycle-length")); + assert!(err.contains("missing required construction input(s): max_cycle_length")); } #[test] @@ -1910,7 +1831,7 @@ fn test_create_job_shop_scheduling_requires_job_tasks() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("JobShopScheduling requires --jobs")); + assert!(err.contains("missing required construction input(s): jobs")); } #[test] @@ -2028,7 +1949,7 @@ fn test_create_stacker_crane_rejects_mismatched_arc_lengths() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("Expected 5 arc costs but got 4")); + assert!(err.contains("arc_lengths length must match arcs length")); } #[test] @@ -2049,7 +1970,7 @@ fn test_create_stacker_crane_rejects_out_of_range_vertices() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("--num-vertices (5) is too small for the arcs")); + assert!(err.contains("num_vertices 5 is too small for the provided endpoints")); } #[test] @@ -2213,7 +2134,8 @@ fn test_create_kclique_requires_valid_k() { let err = create(&args, &out).unwrap_err(); assert!( - err.to_string().contains("KClique requires --k"), + err.to_string() + .contains("missing required construction input(s): k"), "unexpected error: {err}" ); @@ -2278,8 +2200,7 @@ fn test_create_sparse_matrix_compression_requires_bound() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("SparseMatrixCompression requires --matrix and --bound")); - assert!(err.contains("Usage: pred create SparseMatrixCompression")); + assert!(err.contains("missing required construction input(s): bound_k")); } #[test] @@ -2297,47 +2218,7 @@ fn test_create_sparse_matrix_compression_rejects_zero_bound() { }; 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); + assert!(err.contains("bound_k must be positive")); } #[test] @@ -2435,8 +2316,7 @@ fn test_create_consecutive_ones_matrix_augmentation_requires_bound() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("ConsecutiveOnesMatrixAugmentation requires --matrix and --bound")); - assert!(err.contains("Usage: pred create ConsecutiveOnesMatrixAugmentation")); + assert!(err.contains("missing required construction input(s): bound")); } #[test] diff --git a/problemreductions-cli/src/create_args.rs b/problemreductions-cli/src/create_args.rs index 8a369adca..7726da96e 100644 --- a/problemreductions-cli/src/create_args.rs +++ b/problemreductions-cli/src/create_args.rs @@ -166,7 +166,6 @@ fn add_problem_subcommands(mut command: Command) -> Command { let mut subcommand = Command::new(name.clone()) .about(problem.description) .aliases(aliases.iter().cloned()) - .arg_required_else_help(true) .disable_help_subcommand(true); if include_problem_flags { subcommand = subcommand.defer(add_selected_problem_args); @@ -292,10 +291,8 @@ fn join_spec(prefix: &str, values: &[&str]) -> String { fn add_selected_problem_args(mut command: Command) -> Command { let selected = command.get_name().to_string(); - let problem_ref = problemreductions::registry::parse_catalog_problem_ref(&selected) - .unwrap_or_else(|error| panic!("invalid registered create command `{selected}`: {error}")); - let inputs = - crate::commands::create::create_inputs_for(problem_ref.name(), problem_ref.variant()); + let (canonical, variant) = resolve_registered_create_variant(&selected); + let inputs = crate::commands::create::create_inputs_for(canonical, &variant); for input in inputs { let mut arg = Arg::new(input.name.clone()).long(input.name.clone()); @@ -313,6 +310,28 @@ fn add_selected_problem_args(mut command: Command) -> Command { command } +pub(crate) fn resolve_registered_create_variant( + selected: &str, +) -> (&'static str, BTreeMap) { + let mut parts = selected.split('/'); + let canonical = parts.next().expect("registered command has a name"); + let problem = problemreductions::registry::find_problem_type(canonical) + .unwrap_or_else(|| panic!("missing schema for registered create command `{selected}`")); + let values = parts.collect::>(); + + if values.is_empty() { + return variant_entries() + .into_iter() + .find(|entry| entry.name == canonical && entry.is_default) + .map(|entry| (problem.canonical_name, entry.variant_map())) + .unwrap_or_else(|| panic!("missing default variant for `{canonical}`")); + } + + let problem_ref = problemreductions::registry::ProblemRef::from_values(&problem, values) + .unwrap_or_else(|error| panic!("invalid registered create command `{selected}`: {error}")); + (problem.canonical_name, problem_ref.variant().clone()) +} + fn add_value_parser(arg: Arg, kind: crate::commands::create::InputValueKind) -> Arg { use crate::commands::create::InputValueKind; match kind { diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index a77bb16ea..0e72bd164 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -1,638 +1,648 @@ -#[cfg(test)] -mod tests { - use crate::mcp::tools::{FindPathParams, McpServer}; - use crate::test_support::{aggregate_bundle, aggregate_problem_json}; - - fn explicit_route(server: &McpServer, source: &str, target: &str, names: &[&str]) -> String { - let response = server - .find_path_inner(source, target, 2000, None) - .expect("path enumeration"); - let json: serde_json::Value = serde_json::from_str(&response).unwrap(); - let entry = json["paths"] - .as_array() - .unwrap() - .iter() - .find(|entry| { - let edges = entry["path"].as_array().unwrap(); - let mut actual = vec![edges[0]["from"]["name"].as_str().unwrap()]; - actual.extend( - edges - .iter() - .map(|edge| edge["to"]["name"].as_str().unwrap()), - ); - actual == names - }) - .expect("requested explicit route"); - serde_json::to_string(entry).unwrap() - } +use crate::mcp::tools::{FindPathParams, McpServer}; +use crate::test_support::{aggregate_bundle, aggregate_problem_json}; + +fn explicit_route(server: &McpServer, source: &str, target: &str, names: &[&str]) -> String { + let response = server + .find_path_inner(source, target, 2000, None) + .expect("path enumeration"); + let json: serde_json::Value = serde_json::from_str(&response).unwrap(); + let entry = json["paths"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + let edges = entry["path"].as_array().unwrap(); + let mut actual = vec![edges[0]["from"]["name"].as_str().unwrap()]; + actual.extend( + edges + .iter() + .map(|edge| edge["to"]["name"].as_str().unwrap()), + ); + actual == names + }) + .expect("requested explicit route"); + serde_json::to_string(entry).unwrap() +} - #[test] - fn test_list_problems_returns_json() { - let server = McpServer::new(); - let json: serde_json::Value = - serde_json::from_str(&server.list_problems_inner().unwrap()).unwrap(); - assert!(json["num_types"].as_u64().unwrap() > 0); - } +#[test] +fn test_list_problems_returns_json() { + let server = McpServer::new(); + let json: serde_json::Value = + serde_json::from_str(&server.list_problems_inner().unwrap()).unwrap(); + assert!(json["num_types"].as_u64().unwrap() > 0); +} - #[test] - fn test_show_problem_known_and_unknown() { - let server = McpServer::new(); - assert!(server.show_problem_inner("MIS").is_ok()); - assert!(server.show_problem_inner("NonExistent").is_err()); - } +#[test] +fn test_show_problem_known_and_unknown() { + let server = McpServer::new(); + assert!(server.show_problem_inner("MIS").is_ok()); + assert!(server.show_problem_inner("NonExistent").is_err()); +} - #[test] - fn test_find_path_enumerates_without_a_mode_or_sizes() { - let server = McpServer::new(); - let result: serde_json::Value = serde_json::from_str( - &server - .find_path_inner( - "MIS/SimpleGraph/i32", - "MaximumClique/SimpleGraph/i32", - 20, - None, - ) - .unwrap(), - ) - .unwrap(); - assert!(!result["paths"].as_array().unwrap().is_empty()); - assert_eq!(result["analysis"], "symbolic"); - } +#[test] +fn test_find_path_enumerates_without_a_mode_or_sizes() { + let server = McpServer::new(); + let result: serde_json::Value = serde_json::from_str( + &server + .find_path_inner( + "MIS/SimpleGraph/i32", + "MaximumClique/SimpleGraph/i32", + 20, + None, + ) + .unwrap(), + ) + .unwrap(); + assert!(!result["paths"].as_array().unwrap().is_empty()); + assert_eq!(result["analysis"], "symbolic"); +} - #[test] - fn test_find_path_executes_complete_instance_and_reports_actual_size() { - let server = McpServer::new(); - let problem_json = r#"{ +#[test] +fn test_find_path_executes_complete_instance_and_reports_actual_size() { + let server = McpServer::new(); + let problem_json = r#"{ "type":"MaximumIndependentSet", "variant":{"graph":"SimpleGraph","weight":"i32"}, "data":{"graph":{"num_vertices":5,"edges":[[0,1],[1,2],[2,3],[3,4]]},"weights":[1,1,1,1,1]} }"#; - let result: serde_json::Value = serde_json::from_str( - &server - .find_path_inner( - "MIS/SimpleGraph/i32", - "MaximumClique/SimpleGraph/i32", - 20, - Some(problem_json), - ) - .unwrap(), - ) + let result: serde_json::Value = serde_json::from_str( + &server + .find_path_inner( + "MIS/SimpleGraph/i32", + "MaximumClique/SimpleGraph/i32", + 20, + Some(problem_json), + ) + .unwrap(), + ) + .unwrap(); + let fields = result["paths"][0]["actual_target_size"]["fields"] + .as_array() .unwrap(); - let fields = result["paths"][0]["actual_target_size"]["fields"] - .as_array() - .unwrap(); - let edges = fields - .iter() - .find(|field| field["field"] == "num_edges") - .unwrap(); - assert_eq!(edges["value"], 6); - assert_eq!(result["analysis"], "concrete"); - } - - #[test] - fn test_find_path_schema_accepts_complete_problem_json() { - let params: FindPathParams = serde_json::from_value(serde_json::json!({ - "source": "MIS", - "target": "MaximumClique", - "problem_json": "{\"type\":\"MaximumIndependentSet\",\"variant\":{},\"data\":{}}" - })) + let edges = fields + .iter() + .find(|field| field["field"] == "num_edges") .unwrap(); - assert!(params - .problem_json - .unwrap() - .contains("MaximumIndependentSet")); - } + assert_eq!(edges["value"], 6); + assert_eq!(result["analysis"], "concrete"); +} - #[test] - fn test_find_path_is_capped_explicitly() { - let server = McpServer::new(); - let json: serde_json::Value = - serde_json::from_str(&server.find_path_inner("MIS", "QUBO", 1, None).unwrap()).unwrap(); - assert_eq!(json["paths"].as_array().unwrap().len(), 1); - assert_eq!(json["returned"], 1); - assert_eq!(json["max_paths"], 1); - assert_eq!(json["truncated"], true); - assert_eq!(json["analysis"], "symbolic"); - } +#[test] +fn test_find_path_schema_accepts_complete_problem_json() { + let params: FindPathParams = serde_json::from_value(serde_json::json!({ + "source": "MIS", + "target": "MaximumClique", + "problem_json": "{\"type\":\"MaximumIndependentSet\",\"variant\":{},\"data\":{}}" + })) + .unwrap(); + assert!(params + .problem_json + .unwrap() + .contains("MaximumIndependentSet")); +} - #[test] - fn test_neighbors_and_export_graph() { - let server = McpServer::new(); - assert!(server.neighbors_inner("MIS", 1, "out").is_ok()); - assert!(server.neighbors_inner("MIS", 1, "invalid").is_err()); - let graph: serde_json::Value = - serde_json::from_str(&server.export_graph_inner().unwrap()).unwrap(); - assert!(graph.is_object()); - } +#[test] +fn test_find_path_is_capped_explicitly() { + let server = McpServer::new(); + let json: serde_json::Value = + serde_json::from_str(&server.find_path_inner("MIS", "QUBO", 1, None).unwrap()).unwrap(); + assert_eq!(json["paths"].as_array().unwrap().len(), 1); + assert_eq!(json["returned"], 1); + assert_eq!(json["max_paths"], 1); + assert_eq!(json["truncated"], true); + assert_eq!(json["analysis"], "symbolic"); +} - // -- Instance tool tests -------------------------------------------------- +#[test] +fn test_neighbors_and_export_graph() { + let server = McpServer::new(); + assert!(server.neighbors_inner("MIS", 1, "out").is_ok()); + assert!(server.neighbors_inner("MIS", 1, "invalid").is_err()); + let graph: serde_json::Value = + serde_json::from_str(&server.export_graph_inner().unwrap()).unwrap(); + assert!(graph.is_object()); +} - fn create_test_mis(server: &McpServer) -> String { - let params = serde_json::json!({"edges": "0-1,1-2,2-3"}); - server.create_problem_inner("MIS", ¶ms).unwrap() - } +// -- Instance tool tests -------------------------------------------------- - #[test] - fn test_create_problem_mis() { - let server = McpServer::new(); - let params = serde_json::json!({"edges": "0-1,1-2,2-3"}); - let result = server.create_problem_inner("MIS", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "MaximumIndependentSet"); - } +fn create_test_mis(server: &McpServer) -> String { + let params = serde_json::json!({"graph": [[0, 1], [1, 2], [2, 3]]}); + server + .create_problem_inner("MIS/SimpleGraph/i32", ¶ms) + .unwrap() +} - #[test] - fn test_create_problem_sat() { - let server = McpServer::new(); - let params = serde_json::json!({"num_vars": 3, "clauses": "1,2;-1,3"}); - let result = server.create_problem_inner("SAT", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "Satisfiability"); - } +#[test] +fn test_create_problem_mis() { + let server = McpServer::new(); + let params = serde_json::json!({"graph": [[0, 1], [1, 2], [2, 3]]}); + let result = server.create_problem_inner("MIS", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "MaximumIndependentSet"); +} - #[test] - fn test_create_problem_qubo() { - let server = McpServer::new(); - let params = serde_json::json!({"matrix": "1,0.5;0.5,2"}); - let result = server.create_problem_inner("QUBO", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "QUBO"); - } +#[test] +fn test_create_problem_sat() { + let server = McpServer::new(); + let params = serde_json::json!({ + "num_vars": 3, + "clauses": [{"literals": [1, 2]}, {"literals": [-1, 3]}] + }); + let result = server.create_problem_inner("SAT", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "Satisfiability"); +} - #[test] - fn test_create_problem_maxcut() { - let server = McpServer::new(); - let params = serde_json::json!({"edges": "0-1,1-2,2-0"}); - let result = server.create_problem_inner("MaxCut", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "MaxCut"); - } +#[test] +fn test_create_problem_qubo() { + let server = McpServer::new(); + let params = serde_json::json!({"matrix": [[1.0, 0.5], [0.5, 2.0]]}); + let result = server.create_problem_inner("QUBO", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "QUBO"); +} - #[test] - fn test_create_problem_longest_circuit() { - let server = McpServer::new(); - let params = serde_json::json!({ - "edges": "0-1,1-2,2-0", - "edge_lengths": "2,3,4" - }); - let result = server.create_problem_inner("LongestCircuit", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "LongestCircuit"); - assert_eq!(json["data"]["edge_lengths"], serde_json::json!([2, 3, 4])); - } +#[test] +fn test_create_problem_maxcut() { + let server = McpServer::new(); + let params = serde_json::json!({"graph": [[0, 1], [1, 2], [2, 0]]}); + let result = server.create_problem_inner("MaxCut", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "MaxCut"); +} - #[test] - fn test_create_problem_longest_circuit_random() { - let server = McpServer::new(); - let params = serde_json::json!({ - "random": true, - "num_vertices": 5, - "seed": 7 - }); - let result = server.create_problem_inner("LongestCircuit", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "LongestCircuit"); - assert_eq!(json["data"]["graph"]["num_vertices"], 5); - assert!(json["data"]["edge_lengths"] - .as_array() - .unwrap() - .iter() - .all(|length| length == 1)); - } +#[test] +fn test_create_problem_longest_circuit() { + let server = McpServer::new(); + let params = serde_json::json!({ + "graph": [[0, 1], [1, 2], [2, 0]], + "edge_weights": [2, 3, 4] + }); + let result = server.create_problem_inner("LongestCircuit", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!(json["data"]["edge_lengths"], serde_json::json!([2, 3, 4])); +} - #[test] - fn test_create_problem_kcoloring() { - let server = McpServer::new(); - let params = serde_json::json!({"edges": "0-1,1-2,2-0", "k": 3}); - let result = server.create_problem_inner("KColoring", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "KColoring"); - } +#[test] +fn test_create_problem_longest_circuit_random() { + let server = McpServer::new(); + let params = serde_json::json!({ + "random": true, + "num_vertices": 5, + "seed": 7 + }); + let result = server.create_problem_inner("LongestCircuit", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!(json["data"]["graph"]["num_vertices"], 5); + assert!(json["data"]["edge_lengths"] + .as_array() + .unwrap() + .iter() + .all(|length| length == 1)); +} - #[test] - fn test_create_problem_factoring() { - let server = McpServer::new(); - let params = serde_json::json!({"target": 15, "bits_m": 4, "bits_n": 4}); - let result = server.create_problem_inner("Factoring", ¶ms); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "Factoring"); - } +#[test] +fn test_create_problem_kcoloring() { + let server = McpServer::new(); + let params = serde_json::json!({"graph": [[0, 1], [1, 2], [2, 0]], "k": 3}); + let result = server.create_problem_inner("KColoring", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "KColoring"); +} - #[test] - fn test_create_problem_unknown() { - let server = McpServer::new(); - let params = serde_json::json!({"edges": "0-1"}); - let result = server.create_problem_inner("NonExistent", ¶ms); - assert!(result.is_err()); - } +#[test] +fn test_create_problem_factoring() { + let server = McpServer::new(); + let params = serde_json::json!({"target": 15, "m": 4, "n": 4}); + let result = server.create_problem_inner("Factoring", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "Factoring"); +} - #[test] - fn test_create_problem_missing_edges() { - let server = McpServer::new(); - let params = serde_json::json!({}); - let result = server.create_problem_inner("MIS", ¶ms); - assert!(result.is_err()); - } +#[test] +fn test_create_problem_unknown() { + let server = McpServer::new(); + let params = serde_json::json!({"edges": "0-1"}); + let result = server.create_problem_inner("NonExistent", ¶ms); + assert!(result.is_err()); +} - #[test] - fn test_inspect_problem() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok(), "inspect failed: {result:?}"); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["type"], "MaximumIndependentSet"); - assert_eq!(json["kind"], "problem"); - assert!(json["num_variables"].as_u64().unwrap() > 0); - } +#[test] +fn test_create_problem_missing_edges() { + let server = McpServer::new(); + let params = serde_json::json!({}); + let result = server.create_problem_inner("MIS", ¶ms); + assert!(result.is_err()); +} - #[test] - fn test_evaluate() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.evaluate_inner(&problem_json, &[1, 0, 1, 0]); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["problem"], "MaximumIndependentSet"); - assert_eq!(json["config"], serde_json::json!([1, 0, 1, 0])); - } +#[test] +fn test_inspect_problem() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.inspect_problem_inner(&problem_json); + assert!(result.is_ok(), "inspect failed: {result:?}"); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "MaximumIndependentSet"); + assert_eq!(json["kind"], "problem"); + assert!(json["num_variables"].as_u64().unwrap() > 0); +} - #[test] - fn test_evaluate_wrong_config_length() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.evaluate_inner(&problem_json, &[1, 0]); - assert!(result.is_err()); - } +#[test] +fn test_evaluate() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.evaluate_inner(&problem_json, &[1, 0, 1, 0]); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["problem"], "MaximumIndependentSet"); + assert_eq!(json["config"], serde_json::json!([1, 0, 1, 0])); +} + +#[test] +fn test_evaluate_wrong_config_length() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.evaluate_inner(&problem_json, &[1, 0]); + assert!(result.is_err()); +} - #[test] - fn test_reduce() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let route = explicit_route( - &server, - "MIS/SimpleGraph/i32", +#[test] +fn test_reduce() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let route = explicit_route( + &server, + "MIS/SimpleGraph/i32", + "QUBO", + &[ + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", "QUBO", - &[ - "MaximumIndependentSet", - "MaximumSetPacking", - "MaximumSetPacking", - "QUBO", - ], - ); - let result = server.reduce_inner(&problem_json, &route); - assert!(result.is_ok(), "{result:?}"); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["target"].is_object()); - assert!(json["source"].is_object()); - assert!(json["path"].is_array()); - } + ], + ); + let result = server.reduce_inner(&problem_json, &route); + assert!(result.is_ok(), "{result:?}"); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert!(json["target"].is_object()); + assert!(json["source"].is_object()); + assert!(json["path"].is_array()); +} - #[test] - fn test_reduce_unknown_target() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.reduce_inner(&problem_json, "{}"); - assert!(result.is_err()); - } +#[test] +fn test_reduce_unknown_target() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.reduce_inner(&problem_json, "{}"); + assert!(result.is_err()); +} - #[test] - fn test_reduce_rejects_discontinuous_explicit_route() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let route = explicit_route( - &server, - "MIS/SimpleGraph/i32", +#[test] +fn test_reduce_rejects_discontinuous_explicit_route() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let route = explicit_route( + &server, + "MIS/SimpleGraph/i32", + "QUBO", + &[ + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", "QUBO", - &[ - "MaximumIndependentSet", - "MaximumSetPacking", - "MaximumSetPacking", - "QUBO", - ], - ); - let mut route: serde_json::Value = serde_json::from_str(&route).unwrap(); - route["path"][1]["from"]["name"] = serde_json::json!("MinimumVertexCover"); - let error = server - .reduce_inner(&problem_json, &route.to_string()) - .expect_err("discontinuous route must be rejected"); - assert!(error.to_string().contains("not continuous")); - } + ], + ); + let mut route: serde_json::Value = serde_json::from_str(&route).unwrap(); + route["path"][1]["from"]["name"] = serde_json::json!("MinimumVertexCover"); + let error = server + .reduce_inner(&problem_json, &route.to_string()) + .expect_err("discontinuous route must be rejected"); + assert!(error.to_string().contains("not continuous")); +} - #[test] - fn test_solve() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.solve_inner(&problem_json, Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["solution"].is_array()); - assert_eq!(json["solver"]["kind"], "brute-force"); - } +#[test] +fn test_solve() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.solve_inner(&problem_json, Some("brute-force"), None); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert!(json["solution"].is_array()); + assert_eq!(json["solver"]["kind"], "brute-force"); +} - #[test] - fn test_solve_ilp() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let result = server.solve_inner(&problem_json, Some("ilp"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["solution"].is_array()); - } +#[test] +fn test_solve_ilp() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let result = server.solve_inner(&problem_json, Some("ilp"), None); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert!(json["solution"].is_array()); +} - #[test] - fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { - let server = McpServer::new(); - let problem_json = serde_json::json!({ - "type": "MinimumCardinalityKey", - "variant": {}, - "data": { - "num_attributes": 4, - "dependencies": [[[0], [1, 2]], [[1, 2], [3]]], - "bound": 2 - } - }) - .to_string(); - - let result = server.solve_inner(&problem_json, None, None); - assert!(result.is_ok(), "solve failed: {:?}", result); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"]["kind"], "native"); - assert_eq!( - json["solver"]["implementation"], - "fd-minimum-cardinality-key" +#[test] +fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "MinimumCardinalityKey", + "variant": {}, + "data": { + "num_attributes": 4, + "dependencies": [[[0], [1, 2]], [[1, 2], [3]]], + "bound": 2 + } + }) + .to_string(); + + let result = server.solve_inner(&problem_json, None, None); + assert!(result.is_ok(), "solve failed: {:?}", result); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["solver"]["kind"], "native"); + assert_eq!( + json["solver"]["implementation"], + "fd-minimum-cardinality-key" + ); + assert!(json["solution"].is_array(), "{json}"); +} + +#[test] +fn test_solve_unknown_solver() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + let error = server + .solve_inner(&problem_json, Some(rejected), None) + .unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("Unknown solver: {rejected}")), + "unexpected error for {rejected}: {error}" ); - assert!(json["solution"].is_array(), "{json}"); } +} - #[test] - fn test_solve_unknown_solver() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { - let error = server - .solve_inner(&problem_json, Some(rejected), None) - .unwrap_err(); - assert!( - error - .to_string() - .contains(&format!("Unknown solver: {rejected}")), - "unexpected error for {rejected}: {error}" - ); +#[test] +fn deterministic_solver_dispatch_mcp_output_is_repeatable_for_each_solver_class() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "RootedTreeArrangement", + "variant": {"graph": "SimpleGraph"}, + "data": { + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, + "bound": 3 } - } - - #[test] - fn deterministic_solver_dispatch_mcp_output_is_repeatable_for_each_solver_class() { - let server = McpServer::new(); - let problem_json = serde_json::json!({ - "type": "RootedTreeArrangement", - "variant": {"graph": "SimpleGraph"}, - "data": { - "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, - "bound": 3 - } - }) - .to_string(); + }) + .to_string(); - for solver in [None, Some("ilp"), Some("brute-force")] { - let first = server.solve_inner(&problem_json, solver, None).unwrap(); - let second = server.solve_inner(&problem_json, solver, None).unwrap(); - assert_eq!(first, second, "{solver:?} MCP output changed"); - } + for solver in [None, Some("ilp"), Some("brute-force")] { + let first = server.solve_inner(&problem_json, solver, None).unwrap(); + let second = server.solve_inner(&problem_json, solver, None).unwrap(); + assert_eq!(first, second, "{solver:?} MCP output changed"); } +} - #[test] - fn test_solve_bundle() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - // Reduce first, then solve the bundle - let bundle_json = server - .reduce_inner( - &problem_json, - &explicit_route( - &server, - "MIS/SimpleGraph/i32", +#[test] +fn test_solve_bundle() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + // Reduce first, then solve the bundle + let bundle_json = server + .reduce_inner( + &problem_json, + &explicit_route( + &server, + "MIS/SimpleGraph/i32", + "QUBO", + &[ + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", "QUBO", - &[ - "MaximumIndependentSet", - "MaximumSetPacking", - "MaximumSetPacking", - "QUBO", - ], - ), + ], + ), + ) + .unwrap(); + let result = server.solve_inner(&bundle_json, Some("brute-force"), None); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert!(json["solution"].is_array()); + assert_eq!(json["problem"], "MaximumIndependentSet"); +} + +#[test] +fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { + let server = McpServer::new(); + + for (clauses, evaluation, has_solution) in [ + ( + serde_json::json!([{"literals": [1]}, {"literals": [-1]}]), + "Or(false)", + false, + ), + (serde_json::json!([{"literals": [1]}]), "Or(true)", true), + ] { + let problem_json = server + .create_problem_inner( + "Satisfiability", + &serde_json::json!({"num_vars": 1, "clauses": clauses}), ) .unwrap(); - let result = server.solve_inner(&bundle_json, Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["solution"].is_array()); - assert_eq!(json["problem"], "MaximumIndependentSet"); - } - - #[test] - fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { - let server = McpServer::new(); - - for (clauses, evaluation, has_solution) in - [("1;-1", "Or(false)", false), ("1", "Or(true)", true)] - { - let problem_json = server - .create_problem_inner( - "Satisfiability", - &serde_json::json!({"num_vars": 1, "clauses": clauses}), - ) - .unwrap(); - let bundle_json = server - .reduce_inner( - &problem_json, - &explicit_route( - &server, - "Satisfiability", - "NAESatisfiability", - &["Satisfiability", "NAESatisfiability"], - ), - ) - .unwrap(); - let solved = server - .solve_inner(&bundle_json, Some("brute-force"), None) - .unwrap(); - let json: serde_json::Value = serde_json::from_str(&solved).unwrap(); - - assert_eq!(json["evaluation"], evaluation); - assert_eq!(json["solution"].is_array(), has_solution); - assert_eq!(json["intermediate"]["evaluation"], evaluation); - assert_eq!(json["intermediate"]["solution"].is_array(), has_solution); - } - } - - #[test] - fn test_solve_bundle_rejects_removed_customized_override() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); let bundle_json = server .reduce_inner( &problem_json, &explicit_route( &server, - "MIS/SimpleGraph/i32", - "QUBO", - &[ - "MaximumIndependentSet", - "MaximumSetPacking", - "MaximumSetPacking", - "QUBO", - ], + "Satisfiability", + "NAESatisfiability", + &["Satisfiability", "NAESatisfiability"], ), ) .unwrap(); - let result = server.solve_inner(&bundle_json, Some("customized"), None); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Unknown solver: customized"), - "unexpected error: {err}" - ); + let solved = server + .solve_inner(&bundle_json, Some("brute-force"), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&solved).unwrap(); + + assert_eq!(json["evaluation"], evaluation); + assert_eq!(json["solution"].is_array(), has_solution); + assert_eq!(json["intermediate"]["evaluation"], evaluation); + assert_eq!(json["intermediate"]["solution"].is_array(), has_solution); } +} - #[test] - fn test_inspect_bundle() { - let server = McpServer::new(); - let problem_json = create_test_mis(&server); - let bundle_json = server - .reduce_inner( - &problem_json, - &explicit_route( - &server, - "MIS/SimpleGraph/i32", +#[test] +fn test_solve_bundle_rejects_removed_customized_override() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let bundle_json = server + .reduce_inner( + &problem_json, + &explicit_route( + &server, + "MIS/SimpleGraph/i32", + "QUBO", + &[ + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", "QUBO", - &[ - "MaximumIndependentSet", - "MaximumSetPacking", - "MaximumSetPacking", - "QUBO", - ], - ), - ) - .unwrap(); - let result = server.inspect_problem_inner(&bundle_json); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["kind"], "bundle"); - assert_eq!(json["source"], "MaximumIndependentSet"); - } + ], + ), + ) + .unwrap(); + let result = server.solve_inner(&bundle_json, Some("customized"), None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Unknown solver: customized"), + "unexpected error: {err}" + ); +} - #[test] - fn test_inspect_minmaxmulticenter_reports_registered_ilp_pipeline() { - let server = McpServer::new(); - let problem_json = serde_json::json!({ - "type": "MinMaxMulticenter", - "variant": {"graph": "SimpleGraph", "weight": "i32"}, - "data": { - "graph": { - "num_vertices": 4, - "edges": [[0, 1], [1, 2], [2, 3]] - }, - "vertex_weights": [1, 1, 1, 1], - "edge_lengths": [1, 1, 1], - "k": 2 - } - }) - .to_string(); +#[test] +fn test_inspect_bundle() { + let server = McpServer::new(); + let problem_json = create_test_mis(&server); + let bundle_json = server + .reduce_inner( + &problem_json, + &explicit_route( + &server, + "MIS/SimpleGraph/i32", + "QUBO", + &[ + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", + "QUBO", + ], + ), + ) + .unwrap(); + let result = server.inspect_problem_inner(&bundle_json); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["kind"], "bundle"); + assert_eq!(json["source"], "MaximumIndependentSet"); +} - let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok(), "inspect failed: {result:?}"); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["default_solver"], "ilp"); - assert!(json["solver_capabilities"]["ilp"]["reduction_path"].is_array()); - } +#[test] +fn test_inspect_minmaxmulticenter_reports_registered_ilp_pipeline() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "MinMaxMulticenter", + "variant": {"graph": "SimpleGraph", "weight": "i32"}, + "data": { + "graph": { + "num_vertices": 4, + "edges": [[0, 1], [1, 2], [2, 3]] + }, + "vertex_weights": [1, 1, 1, 1], + "edge_lengths": [1, 1, 1], + "k": 2 + } + }) + .to_string(); + + let result = server.inspect_problem_inner(&problem_json); + assert!(result.is_ok(), "inspect failed: {result:?}"); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["default_solver"], "ilp"); + assert!(json["solver_capabilities"]["ilp"]["reduction_path"].is_array()); +} - #[test] - fn test_inspect_minimum_cardinality_key_reports_native_solver() { - let server = McpServer::new(); - let problem_json = serde_json::json!({ - "type": "MinimumCardinalityKey", - "variant": {}, - "data": { - "num_attributes": 4, - "dependencies": [[[0], [1, 2]], [[1, 2], [3]]], - "bound": 2 - } - }) - .to_string(); - - let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok(), "inspect failed: {:?}", result); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["default_solver"], "native"); - assert_eq!( - json["solver_capabilities"]["native"]["implementation"], - "fd-minimum-cardinality-key" - ); - } +#[test] +fn test_inspect_minimum_cardinality_key_reports_native_solver() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "MinimumCardinalityKey", + "variant": {}, + "data": { + "num_attributes": 4, + "dependencies": [[[0], [1, 2]], [[1, 2], [3]]], + "bound": 2 + } + }) + .to_string(); + + let result = server.inspect_problem_inner(&problem_json); + assert!(result.is_ok(), "inspect failed: {:?}", result); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["default_solver"], "native"); + assert_eq!( + json["solver_capabilities"]["native"]["implementation"], + "fd-minimum-cardinality-key" + ); +} - #[test] - fn test_solve_sat_problem() { - let server = McpServer::new(); - let params = serde_json::json!({"num_vars": 2, "clauses": "1;-2"}); - let problem_json = server.create_problem_inner("SAT", ¶ms).unwrap(); - let result = server.solve_inner(&problem_json, Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"]["kind"], "brute-force"); - } +#[test] +fn test_solve_sat_problem() { + let server = McpServer::new(); + let params = serde_json::json!({ + "num_vars": 2, + "clauses": [{"literals": [1]}, {"literals": [-2]}] + }); + let problem_json = server.create_problem_inner("SAT", ¶ms).unwrap(); + let result = server.solve_inner(&problem_json, Some("brute-force"), None); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["solver"]["kind"], "brute-force"); +} - #[test] - fn test_reduce_rejects_aggregate_only_path() { - let server = McpServer::new(); - let route = serde_json::json!({"path": [{ - "from": {"name": "CliTestAggregateValueSource", "variant": {}}, - "to": {"name": "CliTestAggregateValueTarget", "variant": {}} - }]}) - .to_string(); - let result = server.reduce_inner(&aggregate_problem_json(), &route); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("witness"), "unexpected error: {err}"); - } +#[test] +fn test_reduce_rejects_aggregate_only_path() { + let server = McpServer::new(); + let route = serde_json::json!({"path": [{ + "from": {"name": "CliTestAggregateValueSource", "variant": {}}, + "to": {"name": "CliTestAggregateValueTarget", "variant": {}} + }]}) + .to_string(); + let result = server.reduce_inner(&aggregate_problem_json(), &route); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("witness"), "unexpected error: {err}"); +} - #[test] - fn test_solve_aggregate_only_problem_omits_solution() { - let server = McpServer::new(); - let result = server.solve_inner(&aggregate_problem_json(), Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["evaluation"], "Sum(56)"); - assert!(json.get("solution").is_none(), "{json}"); - } +#[test] +fn test_solve_aggregate_only_problem_omits_solution() { + let server = McpServer::new(); + let result = server.solve_inner(&aggregate_problem_json(), Some("brute-force"), None); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["evaluation"], "Sum(56)"); + assert!(json.get("solution").is_none(), "{json}"); +} - #[test] - fn test_solve_ilp_rejects_aggregate_only_problem() { - let server = McpServer::new(); - let result = server.solve_inner(&aggregate_problem_json(), Some("ilp"), None); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("No ILP pipeline is registered"), - "unexpected error: {err}" - ); - } +#[test] +fn test_solve_ilp_rejects_aggregate_only_problem() { + let server = McpServer::new(); + let result = server.solve_inner(&aggregate_problem_json(), Some("ilp"), None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("No ILP pipeline is registered"), + "unexpected error: {err}" + ); +} - #[test] - fn test_solve_bundle_rejects_aggregate_only_path() { - let server = McpServer::new(); - let bundle_json = serde_json::to_string(&aggregate_bundle()).unwrap(); - let result = server.solve_inner(&bundle_json, Some("brute-force"), None); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("witness"), "unexpected error: {err}"); - } +#[test] +fn test_solve_bundle_rejects_aggregate_only_path() { + let server = McpServer::new(); + let bundle_json = serde_json::to_string(&aggregate_bundle()).unwrap(); + let result = server.solve_inner(&bundle_json, Some("brute-force"), None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("witness"), "unexpected error: {err}"); } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 1df7092e6..2e5cbd1a2 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1,11 +1,8 @@ use crate::util; -use problemreductions::models::algebraic::QUBO; -use problemreductions::models::formula::{CNFClause, NonTautology, Satisfiability}; use problemreductions::models::graph::{ KClique, LongestCircuit, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman, }; -use problemreductions::models::misc::Factoring; use problemreductions::registry::collect_schemas; use problemreductions::rules::{ReductionGraph, TraversalFlow}; use problemreductions::solvers::SolverRequest; @@ -21,7 +18,7 @@ use crate::dispatch::{ load_problem, solve_result_json, solver_capabilities_view, solver_request, BundleReplay, ProblemJson, ProblemJsonOutput, ReductionBundle, }; -use crate::problem_name::{aliases_for, resolve_problem_ref, unknown_problem_error}; +use crate::problem_name::{aliases_for, resolve_catalog_problem_ref, resolve_problem_ref}; // --------------------------------------------------------------------------- // Parameter structs — graph query tools @@ -68,7 +65,7 @@ pub struct CreateProblemParams { )] pub problem_type: String, #[schemars( - description = "Problem parameters as JSON object. Graph problems: {\"edges\": \"0-1,1-2\", \"weights\": \"1,2,3\"}. SAT: {\"num_vars\": 3, \"clauses\": \"1,2;-1,3\"}. NonTautology: {\"num_vars\": 2, \"disjuncts\": \"1,2;-1,-2\"}. QUBO: {\"matrix\": \"1,0.5;0.5,2\"}. KColoring: {\"edges\": \"0-1,1-2\", \"k\": 3}. KClique: {\"edges\": \"0-1,0-2,1-3,2-3,2-4,3-4\", \"k\": 3}. Factoring: {\"target\": 15, \"bits_m\": 4, \"bits_n\": 4}. Random graph: {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}. Geometry graphs (use with MIS/KingsSubgraph etc.): {\"positions\": \"0,0;1,0;1,1\"}. UnitDiskGraph: {\"positions\": \"0.0,0.0;1.0,0.0\", \"radius\": 1.5}" + description = "Named JSON construction inputs declared by the selected problem variant. Values must use their JSON types; unknown and missing required inputs are errors. Random graph generation remains available with {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}." )] pub params: serde_json::Value, } @@ -300,14 +297,9 @@ impl McpServer { problem_type: &str, params: &serde_json::Value, ) -> anyhow::Result { - let rgraph = ReductionGraph::new(); - let resolved = resolve_problem_ref(problem_type, &rgraph)?; - let canonical = resolved.name.clone(); - let resolved_variant = resolved.variant; - let graph_type = resolved_variant - .get("graph") - .map(|s| s.as_str()) - .unwrap_or("SimpleGraph"); + let resolved = resolve_catalog_problem_ref(problem_type)?; + let canonical = resolved.name().to_string(); + let resolved_variant = resolved.variant().clone(); // Check for random generation let is_random = params @@ -319,152 +311,14 @@ impl McpServer { return self.create_random_inner(&canonical, &resolved_variant, params); } - let (data, variant) = match canonical.as_str() { - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" => { - create_vertex_weight_from_params(&canonical, graph_type, &resolved_variant, params)? - } - - "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - let (graph, _) = parse_graph_from_params(params)?; - let edge_weights = parse_edge_weights_from_params(params, graph.num_edges())?; - ser_edge_weight_problem(&canonical, graph, edge_weights)? - } - - "LongestCircuit" => { - let (graph, _) = parse_graph_from_params(params)?; - let edge_lengths = parse_edge_lengths_from_params(params, graph.num_edges())?; - if edge_lengths.iter().any(|&length| length <= 0) { - anyhow::bail!("LongestCircuit edge lengths must be positive (> 0)"); - } - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - (ser(LongestCircuit::new(graph, edge_lengths))?, variant) - } - - "KColoring" => { - let (graph, _) = parse_graph_from_params(params)?; - let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); - let (k, _variant) = - util::validate_k_param(&resolved_variant, k_flag, None, "KColoring")?; - util::ser_kcoloring(graph, k)? - } - - "KClique" => { - let (graph, _) = parse_graph_from_params(params)?; - let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); - let k = parse_kclique_threshold(k_flag, graph.num_vertices())?; - ( - ser(KClique::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // SAT - "Satisfiability" => { - let num_vars = params - .get("num_vars") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| anyhow::anyhow!("Satisfiability requires 'num_vars'"))?; - let clauses = parse_clauses_from_params(params)?; - let variant = BTreeMap::new(); - (ser(Satisfiability::new(num_vars, clauses))?, variant) - } - "NonTautology" => { - let num_vars = params - .get("num_vars") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| anyhow::anyhow!("NonTautology requires 'num_vars'"))?; - let disjuncts = parse_disjuncts_from_params(params)?; - let variant = BTreeMap::new(); - (ser(NonTautology::new(num_vars, disjuncts))?, variant) - } - "KSatisfiability" => { - let num_vars = params - .get("num_vars") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| anyhow::anyhow!("KSatisfiability requires 'num_vars'"))?; - let clauses = parse_clauses_from_params(params)?; - let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); - let (k, _variant) = - util::validate_k_param(&resolved_variant, k_flag, Some(3), "KSatisfiability")?; - util::ser_ksat(num_vars, clauses, k)? - } - - // QUBO - "QUBO" => { - let matrix = parse_matrix_from_params(params)?; - let variant = BTreeMap::new(); - (ser(QUBO::from_matrix(matrix))?, variant) - } - - // SpinGlass - "SpinGlass" => { - let (graph, n) = parse_graph_from_params(params)?; - let edge_weights = parse_edge_weights_from_params(params, graph.num_edges())?; - let fields = vec![0i32; n]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SpinGlass::from_graph(graph, edge_weights, fields))?, - variant, - ) - } - - // Factoring - "Factoring" => { - let target = params - .get("target") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Factoring requires 'target'"))?; - let bits_m = params - .get("bits_m") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| anyhow::anyhow!("Factoring requires 'bits_m'"))?; - let bits_n = params - .get("bits_n") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| anyhow::anyhow!("Factoring requires 'bits_n'"))?; - let variant = BTreeMap::new(); - (ser(Factoring::new(bits_m, bits_n, target))?, variant) - } - - // MinimumSumMulticenter (p-median) - "MinimumSumMulticenter" => { - let (graph, n) = parse_graph_from_params(params)?; - let vertex_weights = parse_vertex_weights_from_params(params, n)?; - let edge_lengths = parse_edge_lengths_from_params(params, graph.num_edges())?; - let k = params - .get("k") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| { - anyhow::anyhow!("MinimumSumMulticenter requires 'k' (number of centers)") - })?; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(MinimumSumMulticenter::new( - graph, - vertex_weights, - edge_lengths, - k, - ))?, - variant, - ) - } - - _ => anyhow::bail!("{}", unknown_problem_error(&canonical)), - }; + let normalized = normalize_mcp_create_inputs(params)?; + let problem = + problemreductions::registry::construct_dyn(&canonical, &resolved_variant, normalized)?; let output = ProblemJsonOutput { - problem_type: canonical, - variant, - data, + problem_type: problem.problem_name().to_string(), + variant: problem.variant_map(), + data: problem.serialize_json(), }; Ok(serde_json::to_string_pretty(&output)?) } @@ -965,6 +819,13 @@ fn parse_direction(s: &str) -> anyhow::Result { // Instance tool helpers // --------------------------------------------------------------------------- +fn normalize_mcp_create_inputs(params: &serde_json::Value) -> anyhow::Result { + let inputs = params + .as_object() + .ok_or_else(|| anyhow::anyhow!("construction inputs must be a JSON object"))?; + Ok(serde_json::Value::Object(inputs.clone())) +} + fn ser(problem: T) -> anyhow::Result { util::ser(problem) } @@ -1021,111 +882,6 @@ fn ser_vertex_weight_problem_generic( } } -/// Create a vertex-weight problem from MCP params, dispatching on graph type. -fn create_vertex_weight_from_params( - canonical: &str, - graph_type: &str, - resolved_variant: &BTreeMap, - params: &serde_json::Value, -) -> anyhow::Result<(serde_json::Value, BTreeMap)> { - match graph_type { - "KingsSubgraph" => { - let positions = parse_int_positions_from_params(params)?; - let n = positions.len(); - let graph = KingsSubgraph::new(positions); - let weights = parse_vertex_weights_from_params(params, n)?; - Ok(( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - "TriangularSubgraph" => { - let positions = parse_int_positions_from_params(params)?; - let n = positions.len(); - let graph = TriangularSubgraph::new(positions); - let weights = parse_vertex_weights_from_params(params, n)?; - Ok(( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - "UnitDiskGraph" => { - let positions = parse_float_positions_from_params(params)?; - let n = positions.len(); - let radius = params.get("radius").and_then(|v| v.as_f64()).unwrap_or(1.0); - let graph = UnitDiskGraph::new(positions, radius); - let weights = parse_vertex_weights_from_params(params, n)?; - Ok(( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - )) - } - _ => { - let (graph, n) = parse_graph_from_params(params)?; - let weights = parse_vertex_weights_from_params(params, n)?; - ser_vertex_weight_problem(canonical, graph, weights) - } - } -} - -/// Extract and parse 'positions' param as integer grid positions. -fn parse_int_positions_from_params(params: &serde_json::Value) -> anyhow::Result> { - let pos_str = params - .get("positions") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("This variant requires 'positions' parameter (e.g., \"0,0;1,0;1,1\")") - })?; - util::parse_positions(pos_str, "0,0;1,0;1,1") -} - -/// Extract and parse 'positions' param as float positions. -fn parse_float_positions_from_params( - params: &serde_json::Value, -) -> anyhow::Result> { - let pos_str = params - .get("positions") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!( - "This variant requires 'positions' parameter (e.g., \"0.0,0.0;1.0,0.0\")" - ) - })?; - util::parse_positions(pos_str, "0.0,0.0;1.0,0.0") -} - -/// Parse `edges` field from JSON params into a SimpleGraph. -fn parse_graph_from_params(params: &serde_json::Value) -> anyhow::Result<(SimpleGraph, usize)> { - let edges_str = params - .get("edges") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("This problem requires 'edges' parameter (e.g., \"0-1,1-2,2-3\")") - })?; - - let edges: Vec<(usize, usize)> = edges_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('-').collect(); - if parts.len() != 2 { - anyhow::bail!("Invalid edge '{}': expected format u-v", pair.trim()); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - Ok((u, v)) - }) - .collect::>>()?; - - let num_vertices = edges - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - - Ok((SimpleGraph::new(num_vertices, edges), num_vertices)) -} - fn parse_kclique_threshold(k_flag: Option, num_vertices: usize) -> anyhow::Result { let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires 'k'"))?; if k == 0 { @@ -1137,146 +893,6 @@ fn parse_kclique_threshold(k_flag: Option, num_vertices: usize) -> anyhow Ok(k) } -/// Parse `weights` field from JSON params as vertex weights (i32), defaulting to all 1s. -fn parse_vertex_weights_from_params( - params: &serde_json::Value, - num_vertices: usize, -) -> anyhow::Result> { - match params.get("weights").and_then(|v| v.as_str()) { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_vertices { - anyhow::bail!( - "Expected {} weights but got {}", - num_vertices, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_vertices]), - } -} - -/// Parse `weights` field from JSON params as edge weights (i32), defaulting to all 1s. -fn parse_edge_weights_from_params( - params: &serde_json::Value, - num_edges: usize, -) -> anyhow::Result> { - match params.get("weights").and_then(|v| v.as_str()) { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_edges { - anyhow::bail!( - "Expected {} edge weights but got {}", - num_edges, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_edges]), - } -} - -/// Parse `edge_lengths` field from JSON params as edge lengths (i32), defaulting to all 1s. -fn parse_edge_lengths_from_params( - params: &serde_json::Value, - num_edges: usize, -) -> anyhow::Result> { - match params.get("edge_lengths").and_then(|v| v.as_str()) { - Some(w) => { - let lengths: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if lengths.len() != num_edges { - anyhow::bail!( - "Expected {} edge lengths but got {}", - num_edges, - lengths.len() - ); - } - Ok(lengths) - } - None => Ok(vec![1i32; num_edges]), - } -} - -/// Parse `clauses` field from JSON params as semicolon-separated clauses. -fn parse_clauses_from_params(params: &serde_json::Value) -> anyhow::Result> { - let clauses_str = params - .get("clauses") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("SAT problems require 'clauses' parameter (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() -} - -/// Parse `disjuncts` field from JSON params as semicolon-separated conjunctions. -fn parse_disjuncts_from_params(params: &serde_json::Value) -> anyhow::Result>> { - let disjuncts_str = params - .get("disjuncts") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("NonTautology requires 'disjuncts' parameter (e.g., \"1,2;-1,3\")") - })?; - - disjuncts_str - .split(';') - .map(|disjunct| { - disjunct - .trim() - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>() - .map_err(anyhow::Error::from) - }) - .collect() -} - -/// Parse `matrix` field from JSON params as semicolon-separated rows. -fn parse_matrix_from_params(params: &serde_json::Value) -> anyhow::Result>> { - let matrix_str = params - .get("matrix") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("QUBO requires 'matrix' parameter (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() -} - /// Solve a plain problem and return JSON string. fn solve_problem_inner( problem_type: &str, @@ -1306,14 +922,14 @@ mod tests { use problemreductions::models::formula::NonTautology; #[test] - fn test_create_problem_inner_nontautology_uses_disjuncts() { + fn construction_contract_create_problem_uses_typed_json_inputs() { let server = McpServer::new(); let output = server .create_problem_inner( "NonTautology", &serde_json::json!({ "num_vars": 3, - "disjuncts": "1,2,3;-1,-2,-3", + "disjuncts": [[1, 2, 3], [-1, -2, -3]], }), ) .unwrap(); @@ -1323,4 +939,101 @@ mod tests { let problem: NonTautology = serde_json::from_value(created.data).unwrap(); assert_eq!(problem.disjuncts(), &[vec![1, 2, 3], vec![-1, -2, -3]]); } + + #[test] + fn construction_contract_discovers_model_without_mcp_dispatch() { + let server = McpServer::new(); + let output = server + .create_problem_inner( + crate::test_support::AGGREGATE_SOURCE_NAME, + &serde_json::json!({"values": [2, 5, 7]}), + ) + .unwrap(); + + let created: ProblemJsonOutput = serde_json::from_str(&output).unwrap(); + assert_eq!( + created.problem_type, + crate::test_support::AGGREGATE_SOURCE_NAME + ); + assert!(created.variant.is_empty()); + assert_eq!(created.data, serde_json::json!({"values": [2, 5, 7]})); + } + + #[test] + fn construction_contract_mcp_computes_model_owned_derived_fields() { + let output = McpServer::new() + .create_problem_inner("SCS", &serde_json::json!({"strings": [[0, 1], [1, 2]]})) + .unwrap(); + + let created: ProblemJsonOutput = serde_json::from_str(&output).unwrap(); + assert_eq!(created.problem_type, "ShortestCommonSupersequence"); + assert_eq!(created.data["alphabet_size"], serde_json::json!(3)); + assert_eq!(created.data["max_length"], serde_json::json!(4)); + assert_eq!(created.data["strings"], serde_json::json!([[0, 1], [1, 2]])); + } + + #[test] + fn construction_contract_mcp_builds_composite_model_input() { + let output = McpServer::new() + .create_problem_inner( + "BicliqueCover", + &serde_json::json!({ + "left": 2, + "right": 3, + "biedges": [[0, 0], [0, 1], [1, 2]], + "k": 2, + }), + ) + .unwrap(); + + let created: ProblemJsonOutput = serde_json::from_str(&output).unwrap(); + assert_eq!(created.problem_type, "BicliqueCover"); + assert_eq!(created.data["graph"]["left_size"], serde_json::json!(2)); + assert_eq!(created.data["graph"]["right_size"], serde_json::json!(3)); + assert_eq!(created.data["k"], serde_json::json!(2)); + } + + #[test] + fn construction_contract_rejects_unknown_mcp_input() { + let error = McpServer::new() + .create_problem_inner( + "NonTautology", + &serde_json::json!({ + "num_vars": 3, + "disjuncts": [[1, 2, 3]], + "removed": true, + }), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unknown construction input(s): removed"), + "unexpected error: {error}" + ); + } + + #[test] + fn construction_contract_rejects_missing_mcp_input() { + let error = McpServer::new() + .create_problem_inner("NonTautology", &serde_json::json!({"num_vars": 3})) + .unwrap_err(); + assert!( + error + .to_string() + .contains("missing required construction input(s): disjuncts"), + "unexpected error: {error}" + ); + } + + #[test] + fn construction_contract_rejects_non_object_mcp_input() { + let error = McpServer::new() + .create_problem_inner("NonTautology", &serde_json::json!([])) + .unwrap_err(); + assert_eq!( + error.to_string(), + "construction inputs must be a JSON object" + ); + } } diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 9fce76a91..1d08f642f 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -1,6 +1,8 @@ use crate::dispatch::{PathStep, ProblemJsonOutput, ReductionBundle}; use problemreductions::models::algebraic::{ObjectiveSense, ILP}; -use problemreductions::registry::VariantEntry; +use problemreductions::registry::{ + CreateInputCodec, CreateInputInfo, FieldInfo, ProblemSchemaEntry, VariantEntry, +}; use problemreductions::rules::registry::{ReductionEntry, ReductionSizeDeclarations}; use problemreductions::rules::{AggregateReductionResult, ReductionAutoCast}; use problemreductions::solvers::{BruteForce, Solver}; @@ -13,6 +15,14 @@ use std::collections::BTreeMap; pub(crate) const AGGREGATE_SOURCE_NAME: &str = "CliTestAggregateValueSource"; pub(crate) const AGGREGATE_TARGET_NAME: &str = "CliTestAggregateValueTarget"; +const AGGREGATE_SOURCE_INPUTS: &[CreateInputInfo] = &[CreateInputInfo { + name: "values", + type_name: "Vec", + description: "Values included by selected configuration bits", + required: true, + codec: CreateInputCodec::CommaSeparated, +}]; + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct AggregateValueSource { values: Vec, @@ -119,6 +129,22 @@ where Some((config, evaluation)) } +problemreductions::inventory::submit! { + ProblemSchemaEntry { + name: AggregateValueSource::NAME, + display_name: "CLI test aggregate value source", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Test-only dynamically discovered construction model", + fields: &[FieldInfo { + name: "values", + type_name: "Vec", + description: "Values included by selected configuration bits", + }], + } +} + problemreductions::inventory::submit! { VariantEntry { name: AggregateValueSource::NAME, @@ -127,6 +153,13 @@ problemreductions::inventory::submit! { complexity_eval_fn: |_| 1.0, is_default: true, aliases: &[], + create_inputs: Some(AGGREGATE_SOURCE_INPUTS), + construct_fn: |data| { + problemreductions::registry::validate_create_inputs(AGGREGATE_SOURCE_INPUTS, &data)?; + let problem: AggregateValueSource = serde_json::from_value(data) + .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; + Ok(Box::new(problem)) + }, factory: |data| { let problem: AggregateValueSource = serde_json::from_value(data)?; Ok(Box::new(problem)) @@ -148,6 +181,12 @@ problemreductions::inventory::submit! { complexity_eval_fn: |_| 1.0, is_default: true, aliases: &[], + create_inputs: None, + construct_fn: |data| { + let problem: AggregateValueTarget = serde_json::from_value(data) + .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; + Ok(Box::new(problem)) + }, factory: |data| { let problem: AggregateValueTarget = serde_json::from_value(data)?; Ok(Box::new(problem)) diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index 0f9b08a3d..d33edea1b 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -101,29 +101,6 @@ 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, - k: usize, -) -> Result<(serde_json::Value, BTreeMap)> { - match k { - 2 => Ok(( - ser(KSatisfiability::::new(num_vars, clauses))?, - variant_map(&[("k", "K2")]), - )), - 3 => Ok(( - ser(KSatisfiability::::new(num_vars, clauses))?, - variant_map(&[("k", "K3")]), - )), - _ => Ok(( - ser(KSatisfiability::::new(num_vars, clauses))?, - variant_map(&[("k", "KN")]), - )), - } -} - // --------------------------------------------------------------------------- // Parsing helpers // --------------------------------------------------------------------------- diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 2bcace49f..cb9a5c467 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -664,7 +664,7 @@ fn test_create_undirected_two_commodity_integral_flow_missing_capacities_shows_u .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("requires --capacities")); + assert!(stderr.contains("missing required construction input(s): capacities")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); } @@ -695,7 +695,7 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_invalid_capacity_t .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Invalid capacity `x`")); + assert!(stderr.contains("invalid digit found in string")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); } @@ -726,7 +726,7 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_wrong_capacity_cou .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Expected 3 capacities but got 2")); + assert!(stderr.contains("capacities length must match graph edge count")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); } @@ -759,7 +759,6 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_oversized_capacity .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains(format!("Invalid capacity `{oversized}`").as_str())); assert!(stderr.contains("number too large to fit in target type")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); } @@ -791,7 +790,7 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_out_of_range_termi .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("source-1 must be less than num_vertices (4)")); + assert!(stderr.contains("source_1 must be less than num_vertices")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); } @@ -866,7 +865,7 @@ fn test_create_integral_flow_bundles_missing_bundles_shows_usage() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("requires --bundles")); + assert!(stderr.contains("missing required construction input(s): bundles")); assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); } @@ -895,7 +894,7 @@ fn test_create_integral_flow_bundles_rejects_wrong_bundle_capacity_count() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Expected 3 bundle capacities but got 2")); + assert!(stderr.contains("bundles length must match bundle_capacities length")); assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); } @@ -924,7 +923,7 @@ fn test_create_integral_flow_bundles_rejects_out_of_range_bundle_arc() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("bundle 1 references arc 7")); + assert!(stderr.contains("bundle 1 arc is out of range")); assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); } @@ -1007,7 +1006,7 @@ fn test_create_integral_flow_homologous_arcs_requires_homologous_pairs() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("requires --homologous-pairs")); + assert!(stderr.contains("missing required construction input(s): homologous_pairs")); assert!(stderr.contains("Usage: pred create IntegralFlowHomologousArcs")); } @@ -1034,7 +1033,7 @@ fn test_create_integral_flow_homologous_arcs_rejects_invalid_pair_token() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("u=v")); + assert!(stderr.contains("expected format left=right")); assert!(stderr.contains("Usage: pred create IntegralFlowHomologousArcs")); } @@ -1102,7 +1101,7 @@ fn test_create_integral_flow_with_multipliers_missing_multipliers_shows_usage() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("requires --multipliers")); + assert!(stderr.contains("missing required construction input(s): multipliers")); assert!(stderr.contains("Usage: pred create IntegralFlowWithMultipliers")); } @@ -1129,7 +1128,7 @@ fn test_create_integral_flow_with_multipliers_rejects_wrong_multiplier_count() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Expected 4 multipliers but got 3")); + assert!(stderr.contains("multipliers length must match num_vertices")); assert!(stderr.contains("Usage: pred create IntegralFlowWithMultipliers")); } @@ -1183,7 +1182,7 @@ fn test_create_integral_flow_with_multipliers_rejects_identical_source_and_sink( .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("requires distinct --source and --sink")); + assert!(stderr.contains("source and sink must be distinct")); assert!(stderr.contains("Usage: pred create IntegralFlowWithMultipliers")); } @@ -1194,7 +1193,7 @@ fn test_create_consecutive_block_minimization_rejects_ragged_matrix() { "create", "ConsecutiveBlockMinimization", "--matrix", - "[[true],[true,false]]", + "1;1,0", "--bound-k", "2", ]) @@ -1202,7 +1201,7 @@ fn test_create_consecutive_block_minimization_rejects_ragged_matrix() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("all matrix rows must have the same length")); + assert!(stderr.contains("All rows in --matrix must have the same length")); assert!(stderr.contains("Usage: pred create ConsecutiveBlockMinimization")); assert!(!stderr.contains("panicked at"), "stderr: {stderr}"); } @@ -1680,7 +1679,7 @@ fn test_create_multiprocessor_scheduling_rejects_zero_processors() { "zero processors should return a user error, got panic output: {stderr}" ); assert!( - stderr.contains("requires --num-processors > 0"), + stderr.contains("num_processors must be positive"), "expected a validation error for zero processors, got: {stderr}" ); } @@ -2074,7 +2073,7 @@ fn test_create_comparative_containment_one_rejects_nonunit_weights() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Non-unit weights are not supported for ComparativeContainment/One"), + stderr.contains("expected 1 for One, got 2"), "stderr: {stderr}" ); } @@ -2175,7 +2174,10 @@ fn test_create_set_basis_requires_k() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("SetBasis requires --k"), "stderr: {stderr}"); + assert!( + stderr.contains("missing required construction input(s): k"), + "stderr: {stderr}" + ); } #[test] @@ -2263,7 +2265,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("lengths length (3) must equal weights length (2)"), + stderr.contains("weights length must equal lengths length"), "stderr: {stderr}" ); } @@ -2790,7 +2792,7 @@ fn test_create_mixed_chinese_postman_missing_arcs_shows_usage() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("MixedChinesePostman requires --arcs"), + stderr.contains("missing required construction input(s): arcs"), "expected missing --arcs error, got: {stderr}" ); assert!( @@ -2820,60 +2822,11 @@ fn test_create_mixed_chinese_postman_rejects_edge_weight_length_mismatch() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Expected 4 edge weight"), + stderr.contains("edge_weights length must match num_edges"), "expected edge-weight mismatch diagnostic, got: {stderr}" ); } -#[test] -fn test_create_multiple_choice_branching_rejects_negative_bound() { - let output = pred() - .args([ - "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=-1", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("threshold"), - "stderr should mention the invalid threshold: {stderr}" - ); -} - -#[test] -fn test_create_multiple_choice_branching_rejects_overflowing_bound() { - let output = pred() - .args([ - "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", - "2147483648", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!( - stderr.contains("threshold"), - "stderr should mention the overflowing threshold: {stderr}" - ); -} - #[test] fn test_create_multiple_choice_branching_rejects_invalid_partition_without_panicking() { let output = pred() @@ -3581,7 +3534,7 @@ fn test_create_bounded_component_spanning_forest_rejects_zero_k() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--k >= 1"), "stderr: {stderr}"); + assert!(stderr.contains("k must be at least 1"), "stderr: {stderr}"); } #[test] @@ -3632,7 +3585,10 @@ fn test_create_bounded_component_spanning_forest_rejects_negative_weights() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("nonnegative --weights"), "stderr: {stderr}"); + assert!( + stderr.contains("weights must be nonnegative"), + "stderr: {stderr}" + ); } #[test] @@ -3654,29 +3610,10 @@ fn test_create_bounded_component_spanning_forest_rejects_negative_bound() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("positive --max-weight"), "stderr: {stderr}"); -} - -#[test] -fn test_create_bounded_component_spanning_forest_rejects_out_of_range_bound() { - let output = pred() - .args([ - "create", - "BoundedComponentSpanningForest", - "--graph", - "0-1,1-2,2-3", - "--weights", - "1,1,1,1", - "--k", - "2", - "--max-weight", - "3000000000", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("within i32 range"), "stderr: {stderr}"); + assert!( + stderr.contains("max_weight must be positive"), + "stderr: {stderr}" + ); } #[test] @@ -4524,7 +4461,7 @@ fn test_create_lcs_rejects_empty_strings_without_panicking() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("at least one non-empty string"), + stderr.contains("at least one input string must be non-empty"), "expected user-facing validation error, got: {stderr}" ); assert!( @@ -4788,7 +4725,7 @@ fn test_create_length_bounded_disjoint_paths_rejects_equal_terminals() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--source and --sink must be distinct"), + stderr.contains("source and sink must be distinct"), "expected user-facing validation error, got: {stderr}" ); assert!( @@ -7125,7 +7062,7 @@ fn test_create_bcnf_rejects_out_of_range_attribute_indices() { "CLI should return a user-facing error, got: {stderr}" ); assert!( - stderr.contains("out of range"), + stderr.contains("outside universe of size 3"), "expected out-of-range error, got: {stderr}" ); } @@ -7151,7 +7088,7 @@ fn test_create_bcnf_rejects_out_of_range_lhs_attribute_indices() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("lhs contains attribute index 4"), + stderr.contains("subsets[0] contains attribute 4 outside universe of size 3"), "expected lhs-specific out-of-range error, got: {stderr}" ); } @@ -7177,7 +7114,7 @@ fn test_create_bcnf_rejects_out_of_range_target_attribute_indices() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Target subset contains attribute index 4"), + stderr.contains("target contains attribute 4 outside universe of size 3"), "expected target-specific out-of-range error, got: {stderr}" ); } @@ -7193,9 +7130,9 @@ fn test_create_consistency_of_database_frequency_tables() { "--attribute-domains", "2,3,2", "--frequency-tables", - "0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1", + r#"[{"attribute_a":0,"attribute_b":1,"counts":[[1,1,1],[1,1,1]]},{"attribute_a":1,"attribute_b":2,"counts":[[1,1],[0,2],[1,1]]}]"#, "--known-values", - "0,0,0;3,0,1;1,2,1", + r#"[{"object":0,"attribute":0,"value":0},{"object":3,"attribute":0,"value":1},{"object":1,"attribute":2,"value":1}]"#, ]) .output() .unwrap(); @@ -7386,7 +7323,7 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_missing_costs() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("requires --costs"), + stderr.contains("missing required construction input(s): costs"), "expected missing --costs message, got: {stderr}" ); } @@ -7457,7 +7394,7 @@ fn test_create_multiple_copy_file_allocation_rejects_invalid_usage_values() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("invalid usage list"), + stderr.contains("invalid digit found in string"), "expected usage parse diagnostic, got: {stderr}" ); assert!( @@ -8290,7 +8227,7 @@ fn test_create_shortest_weight_constrained_path_edge_length_count_mismatch() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Expected 8 edge length values but got 7"), + stderr.contains("edge_lengths has 7 entries, expected 8"), "stderr: {stderr}" ); } @@ -8336,7 +8273,7 @@ fn test_create_shortest_weight_constrained_path_rejects_out_of_bounds_source_ver assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("source_vertex 9 out of bounds"), + stderr.contains("source_vertex 9 is outside graph with 6 vertices"), "stderr: {stderr}" ); assert!( @@ -8367,7 +8304,7 @@ fn test_create_shortest_weight_constrained_path_requires_edge_lengths() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("ShortestWeightConstrainedPath requires --edge-lengths"), + stderr.contains("missing required construction input(s): edge_lengths"), "stderr: {stderr}" ); } @@ -8424,7 +8361,7 @@ fn test_create_shortest_weight_constrained_path_rejects_non_positive_edge_length assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("All edge lengths must be positive (> 0)"), + stderr.contains("edge_lengths must be positive"), "stderr: {stderr}" ); } @@ -8442,16 +8379,16 @@ fn test_show_shortest_weight_constrained_path_uses_weight_schema_type_names() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("edge_lengths (Vec)"), - "expected Vec schema type for edge_lengths, got: {stdout}" + stdout.contains("edge_lengths (Vec)"), + "expected concrete Vec construction type for edge_lengths, got: {stdout}" ); assert!( - stdout.contains("edge_weights (Vec)"), - "expected Vec schema type for edge_weights, got: {stdout}" + stdout.contains("edge_weights (Vec)"), + "expected concrete Vec construction type for edge_weights, got: {stdout}" ); assert!( - stdout.contains("weight_bound (W::Sum)"), - "expected W::Sum schema type for weight_bound, got: {stdout}" + stdout.contains("weight_bound (i64)"), + "expected concrete i64 construction type for weight_bound, got: {stdout}" ); } @@ -8490,12 +8427,8 @@ fn test_create_nonunit_weights_require_weighted_variant() { ); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains("Use the weighted variant instead"), - "stderr should point to the explicit weighted variant: {stderr}" - ); - assert!( - stderr.contains("MaximumIndependentSet/SimpleGraph/i32"), - "stderr should include the exact weighted variant: {stderr}" + stderr.contains("expected 1 for One, got 3"), + "stderr should reject non-unit input for the One variant: {stderr}" ); } @@ -8892,7 +8825,7 @@ fn test_create_sequencing_within_intervals_rejects_empty_window() { "expected graceful CLI error, got panic: {stderr}" ); assert!( - stderr.contains("time window is empty"), + stderr.contains("task 0 has an empty time window"), "expected empty-window validation error, got: {stderr}" ); } @@ -8946,7 +8879,7 @@ fn test_create_sequencing_within_intervals_rejects_overflow() { "expected graceful CLI error, got panic: {stderr}" ); assert!( - stderr.contains("overflow computing r(i) + l(i)"), + stderr.contains("task 0 release time plus length overflows u64"), "expected overflow validation error, got: {stderr}" ); } diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs index af648a41c..87488da9b 100644 --- a/problemreductions-macros/src/expr_codegen.rs +++ b/problemreductions-macros/src/expr_codegen.rs @@ -1,3 +1,4 @@ +use num_traits::ToPrimitive; use problemreductions_expr::{Expr, ExprNode}; use proc_macro2::TokenStream; use quote::quote; @@ -150,4 +151,3 @@ mod tests { assert!(constructed.contains("Expr :: pow")); } } -use num_traits::ToPrimitive; diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 820908414..fd71764ce 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -12,7 +12,183 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; use std::collections::{HashMap, HashSet}; -use syn::{parse_macro_input, GenericArgument, ItemImpl, Path, PathArguments, Type}; +use syn::{parse_macro_input, DeriveInput, GenericArgument, ItemImpl, Path, PathArguments, Type}; + +/// Generate static construction-input metadata from a typed create spec. +#[proc_macro_derive(CreateSpec, attributes(create))] +pub fn derive_create_spec(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match generate_create_spec(&input) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +fn generate_create_spec(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + let syn::Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "CreateSpec can only be derived for structs", + )); + }; + let syn::Fields::Named(fields) = &data.fields else { + return Err(syn::Error::new_spanned( + &data.fields, + "CreateSpec requires named fields", + )); + }; + + let mut field_entries = Vec::new(); + let mut input_entries = Vec::new(); + let mut input_renames = Vec::new(); + for field in &fields.named { + let ident = field.ident.as_ref().expect("named field"); + let rust_name = ident.to_string(); + let mut input_name = rust_name.clone(); + let mut codec = quote!(crate::registry::CreateInputCodec::Auto); + for attribute in &field.attrs { + if attribute.path().is_ident("create") { + attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + input_name = meta.value()?.parse::()?.value(); + return Ok(()); + } + if meta.path.is_ident("codec") { + let value = meta.value()?.parse::()?; + codec = create_codec_tokens(&value)?; + return Ok(()); + } + Err(meta.error("expected `name` or `codec`")) + })?; + } + } + if input_name.is_empty() + || !input_name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + return Err(syn::Error::new( + ident.span(), + "construction input names must use non-empty snake_case", + )); + } + + let (value_type, required) = option_inner_type(&field.ty) + .map(|inner| (inner, false)) + .unwrap_or((&field.ty, true)); + let type_name = quote!(#value_type).to_string().replace(' ', ""); + let description = field + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("doc")) + .filter_map(|attribute| match &attribute.meta { + syn::Meta::NameValue(value) => match &value.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) => Some(text.value().trim().to_string()), + _ => None, + }, + _ => None, + }) + .collect::>() + .join(" "); + if input_name != rust_name { + let external_name = syn::LitStr::new(&input_name, ident.span()); + let rust_name = syn::LitStr::new(&rust_name, ident.span()); + input_renames.push(quote! { + if let Some(value) = object.remove(#external_name) { + object.insert(#rust_name.to_string(), value); + } + }); + } + let input_name = syn::LitStr::new(&input_name, ident.span()); + let type_name = syn::LitStr::new(&type_name, ident.span()); + let description = syn::LitStr::new(&description, ident.span()); + field_entries.push(quote! { + crate::registry::FieldInfo { + name: #input_name, + type_name: #type_name, + description: #description, + } + }); + input_entries.push(quote! { + crate::registry::CreateInputInfo { + name: #input_name, + type_name: #type_name, + description: #description, + required: #required, + codec: #codec, + } + }); + } + + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + Ok(quote! { + impl #impl_generics crate::registry::CreateSpec for #name #type_generics #where_clause { + const FIELDS: &'static [crate::registry::FieldInfo] = &[ + #(#field_entries),* + ]; + const INPUTS: &'static [crate::registry::CreateInputInfo] = &[ + #(#input_entries),* + ]; + + fn deserialize_inputs( + mut data: serde_json::Value, + ) -> Result + where + Self: serde::de::DeserializeOwned, + { + let object = data + .as_object_mut() + .expect("construction inputs were validated as an object"); + #(#input_renames)* + serde_json::from_value(data) + } + } + }) +} + +fn create_codec_tokens(value: &syn::LitStr) -> syn::Result { + let variant = match value.value().as_str() { + "auto" => quote!(Auto), + "scalar" => quote!(Scalar), + "json" => quote!(Json), + "comma-separated" => quote!(CommaSeparated), + "semicolon-separated" => quote!(SemicolonSeparated), + "edge-list" => quote!(EdgeList), + "arc-list" => quote!(ArcList), + "bipartite-edge-list" => quote!(BipartiteEdgeList), + "equality-pair-list" => quote!(EqualityPairList), + "functional-dependency-list" => quote!(FunctionalDependencyList), + "character-rows" => quote!(CharacterRows), + _ => { + return Err(syn::Error::new( + value.span(), + "unknown construction codec; expected one of: auto, scalar, json, comma-separated, semicolon-separated, edge-list, arc-list, bipartite-edge-list, equality-pair-list, functional-dependency-list, character-rows", + )) + } + }; + Ok(quote!(crate::registry::CreateInputCodec::#variant)) +} + +fn option_inner_type(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { + return None; + }; + let segment = path.path.segments.last()?; + if segment.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return None; + }; + arguments.args.iter().find_map(|argument| match argument { + GenericArgument::Type(inner) => Some(inner), + _ => None, + }) +} /// Attribute macro for automatic reduction registration. /// @@ -444,6 +620,7 @@ struct DeclareVariantEntry { ty: Type, complexity: syn::LitStr, aliases: Vec, + create_spec: Option, } impl syn::parse::Parse for DeclareVariantsInput { @@ -460,15 +637,13 @@ impl syn::parse::Parse for DeclareVariantsInput { input.parse::]>()?; let complexity: syn::LitStr = input.parse()?; - // Optional: `aliases ["X", "Y", ...]` - let aliases = if input.peek(syn::Ident) { - let fork = input.fork(); - let ident: syn::Ident = fork.parse()?; + let mut aliases = Vec::new(); + let mut create_spec = None; + while input.peek(syn::Ident) { + let ident: syn::Ident = input.parse()?; if ident == "aliases" { - input.parse::()?; let content; syn::bracketed!(content in input); - let mut out = Vec::new(); while !content.is_empty() { let lit: syn::LitStr = content.parse()?; if lit.value().trim().is_empty() { @@ -477,29 +652,30 @@ impl syn::parse::Parse for DeclareVariantsInput { "variant alias must not be empty or whitespace-only", )); } - out.push(lit); + aliases.push(lit); if content.peek(syn::Token![,]) { content.parse::()?; } } - out - } else if fork.peek(syn::token::Bracket) { + } else if ident == "create" { + if create_spec.is_some() { + return Err(syn::Error::new(ident.span(), "duplicate `create` clause")); + } + create_spec = Some(input.parse()?); + } else { return Err(syn::Error::new( ident.span(), - format!("expected 'aliases', found '{ident}'"), + format!("expected `aliases` or `create`, found `{ident}`"), )); - } else { - Vec::new() } - } else { - Vec::new() - }; + } entries.push(DeclareVariantEntry { is_default, ty, complexity, aliases, + create_spec, }); if input.peek(syn::Token![,]) { @@ -582,6 +758,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result = entry.aliases.iter().map(|s| s.value()).collect(); @@ -631,7 +808,36 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result::INPUTS), + construct_fn: |data: serde_json::Value| -> Result, crate::registry::ConstructionError> { + crate::registry::validate_create_inputs( + <#create_spec as crate::registry::CreateSpec>::INPUTS, + &data, + )?; + let spec: #create_spec = <#create_spec as crate::registry::CreateSpec>::deserialize_inputs(data) + .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string()))?; + let problem: #ty = <#ty as std::convert::TryFrom<#create_spec>>::try_from(spec) + .map_err(|error| crate::registry::ConstructionError::Conversion(error.to_string()))?; + Ok(Box::new(problem)) + }, + } + } else { + quote! { + create_inputs: None, + construct_fn: |data: serde_json::Value| -> Result, crate::registry::ConstructionError> { + let problem_type = <#ty as crate::traits::Problem>::problem_type(); + crate::registry::validate_direct_create_inputs(problem_type.fields, &data)?; + let problem: #ty = serde_json::from_value(data) + .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string()))?; + Ok(Box::new(problem)) + }, + } + }; + let dispatch_fields = quote! { + #construction_fields factory: |data: serde_json::Value| -> Result, serde_json::Error> { let p: #ty = serde_json::from_value(data)?; Ok(Box::new(p)) @@ -845,7 +1051,95 @@ mod tests { Ok(_) => panic!("unknown aliases keyword should be rejected"), Err(err) => err, }; - assert_eq!(err.to_string(), "expected 'aliases', found 'nicknames'"); + assert_eq!( + err.to_string(), + "expected `aliases` or `create`, found `nicknames`" + ); + } + + #[test] + fn create_spec_derive_generates_required_optional_and_codec_metadata() { + let input: DeriveInput = syn::parse_quote! { + struct ExampleCreateSpec { + /// Required edge data. + #[create(name = "edges", codec = "edge-list")] + graph_edges: Vec<(usize, usize)>, + /// Optional limit. + limit: Option, + } + }; + let tokens = generate_create_spec(&input).unwrap().to_string(); + assert!(tokens.contains("CreateSpec for ExampleCreateSpec")); + assert!(tokens.contains("const FIELDS")); + assert!(tokens.contains("crate :: registry :: FieldInfo")); + assert!(tokens.contains("name : \"edges\"")); + assert!(tokens.contains("type_name : \"Vec<(usize,usize)>\"")); + assert!(tokens.contains("required : true")); + assert!(tokens.contains("required : false")); + assert!(tokens.contains("CreateInputCodec :: EdgeList")); + assert!(tokens.contains("Required edge data.")); + } + + #[test] + fn create_spec_derive_rejects_unknown_codec() { + let input: DeriveInput = syn::parse_quote! { + struct ExampleCreateSpec { + #[create(codec = "model-specific")] + value: usize, + } + }; + let error = generate_create_spec(&input).unwrap_err(); + assert!(error.to_string().contains("unknown construction codec")); + } + + #[test] + fn create_spec_derive_supports_generics() { + let input: DeriveInput = syn::parse_quote! { + struct ExampleCreateSpec + where + T: Clone, + { + /// Generic value. + value: T, + } + }; + let tokens = generate_create_spec(&input).unwrap().to_string(); + assert!(tokens.contains("impl < T > crate :: registry :: CreateSpec")); + assert!(tokens.contains("for ExampleCreateSpec < T >")); + assert!(tokens.contains("where T : Clone")); + } + + #[test] + fn declare_variants_generates_custom_constructor() { + let input: DeclareVariantsInput = syn::parse_quote! { + default Foo => "1" create FooCreateSpec aliases ["F"], + }; + let tokens = generate_declare_variants(&input).unwrap().to_string(); + assert!(tokens.contains("create_inputs : Some")); + assert!(tokens.contains("FooCreateSpec as crate :: registry :: CreateSpec")); + assert!(tokens.contains("TryFrom < FooCreateSpec >")); + assert!(tokens.contains("validate_create_inputs")); + } + + #[test] + fn declare_variants_generates_direct_constructor_by_default() { + let input: DeclareVariantsInput = syn::parse_quote! { + default Foo => "1", + }; + let tokens = generate_declare_variants(&input).unwrap().to_string(); + assert!(tokens.contains("create_inputs : None")); + assert!(tokens.contains("validate_direct_create_inputs")); + assert!(tokens.contains("construct_fn :")); + } + + #[test] + fn declare_variants_rejects_duplicate_create_clause() { + let error = syn::parse_str::( + "default Foo => \"1\" create First create Second", + ) + .err() + .expect("duplicate create clause must fail"); + assert_eq!(error.to_string(), "duplicate `create` clause"); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 0fa32ece2..d590510b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,7 +128,7 @@ pub use types::{ }; // Re-export proc macros for reduction registration and variant declaration -pub use problemreductions_macros::{declare_variants, reduction}; +pub use problemreductions_macros::{declare_variants, reduction, CreateSpec}; // Re-export inventory so `declare_variants!` can use `$crate::inventory::submit!` pub use inventory; diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index 3e3410538..2070593a2 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -3,7 +3,7 @@ //! Given a lattice basis B and target vector t, find integer coefficients x //! minimizing ‖Bx - t‖₂. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], module_path: module_path!(), description: "Find the closest lattice point to a target vector", - fields: &[ - FieldInfo { name: "basis", type_name: "Vec>", description: "Basis matrix B as column vectors" }, - FieldInfo { name: "target", type_name: "Vec", description: "Target vector t" }, - FieldInfo { name: "bounds", type_name: "Vec", description: "Integer bounds per variable" }, - ], + fields: ClosestVectorProblemI32CreateSpec::FIELDS, } } @@ -153,6 +149,52 @@ pub struct ClosestVectorProblem { bounds: Vec, } +macro_rules! cvp_create_spec { + ($name:ident, $element:ty) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Basis matrix as semicolon-separated column vectors. + #[create(codec = "semicolon-separated")] + basis: Vec>, + /// Target vector. + #[create(name = "target_vec", codec = "comma-separated")] + target: Vec, + /// Shared lower and upper coefficient bounds. + #[create(codec = "comma-separated")] + bounds: Option>, + } + + impl TryFrom<$name> for ClosestVectorProblem<$element> { + type Error = String; + + fn try_from(spec: $name) -> Result { + for (index, column) in spec.basis.iter().enumerate() { + if column.len() != spec.target.len() { + return Err(format!( + "basis vector {index} has length {}, expected {}", + column.len(), + spec.target.len() + )); + } + } + let limits = spec.bounds.unwrap_or_else(|| vec![-10, 10]); + if limits.len() != 2 { + return Err("bounds expects exactly lower,upper".to_string()); + } + let bounds = vec![VarBounds::bounded(limits[0], limits[1]); spec.basis.len()]; + Ok(ClosestVectorProblem { + basis: spec.basis, + target: spec.target, + bounds, + }) + } + } + }; +} + +cvp_create_spec!(ClosestVectorProblemI32CreateSpec, i32); +cvp_create_spec!(ClosestVectorProblemF64CreateSpec, f64); + impl ClosestVectorProblem { /// Create a new CVP instance. /// @@ -275,8 +317,8 @@ where } crate::declare_variants! { - default ClosestVectorProblem => "2^num_basis_vectors", - ClosestVectorProblem => "2^num_basis_vectors", + default ClosestVectorProblem => "2^num_basis_vectors" create ClosestVectorProblemI32CreateSpec, + ClosestVectorProblem => "2^num_basis_vectors" create ClosestVectorProblemF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/consecutive_block_minimization.rs b/src/models/algebraic/consecutive_block_minimization.rs index 0a5df44df..d816abed5 100644 --- a/src/models/algebraic/consecutive_block_minimization.rs +++ b/src/models/algebraic/consecutive_block_minimization.rs @@ -8,7 +8,7 @@ //! A "block" is a maximal contiguous run of 1-entries in a row. //! This is problem SR17 in Garey & Johnson. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,10 +20,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Permute columns of a binary matrix to have at most K consecutive blocks of 1s", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "Binary matrix A (m x n)" }, - FieldInfo { name: "bound", type_name: "i64", description: "Upper bound K on total consecutive blocks" }, - ], + fields: ConsecutiveBlockMinimizationCreateSpec::FIELDS, } } @@ -73,6 +70,22 @@ pub struct ConsecutiveBlockMinimization { bound: i64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConsecutiveBlockMinimizationCreateSpec { + /// Binary matrix A (m x n). + matrix: Vec>, + /// Upper bound K on total consecutive blocks. + bound_k: i64, +} + +impl TryFrom for ConsecutiveBlockMinimization { + type Error = String; + + fn try_from(spec: ConsecutiveBlockMinimizationCreateSpec) -> Result { + Self::try_new(spec.matrix, spec.bound_k) + } +} + impl ConsecutiveBlockMinimization { /// Create a new ConsecutiveBlockMinimization problem. /// @@ -184,7 +197,7 @@ impl Problem for ConsecutiveBlockMinimization { } crate::declare_variants! { - default ConsecutiveBlockMinimization => "factorial(num_cols) * num_rows * num_cols", + default ConsecutiveBlockMinimization => "factorial(num_cols) * num_rows * num_cols" create ConsecutiveBlockMinimizationCreateSpec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs index 079d7bcd6..10daa96cd 100644 --- a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -4,7 +4,7 @@ //! whether there exists a permutation of the columns and at most K zero-to-one //! augmentations such that every row has consecutive 1s. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Augment a binary matrix with at most K zero-to-one flips so some column permutation has the consecutive ones property", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "m x n binary matrix A" }, - FieldInfo { name: "bound", type_name: "i64", description: "Upper bound K on zero-to-one augmentations" }, - ], + fields: ConsecutiveOnesMatrixAugmentationCreateSpec::FIELDS, } } @@ -29,6 +26,20 @@ pub struct ConsecutiveOnesMatrixAugmentation { bound: i64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConsecutiveOnesMatrixAugmentationCreateSpec { + /// m x n binary matrix A. + matrix: Vec>, + /// Upper bound K on zero-to-one augmentations. + bound: i64, +} +impl TryFrom for ConsecutiveOnesMatrixAugmentation { + type Error = String; + fn try_from(spec: ConsecutiveOnesMatrixAugmentationCreateSpec) -> Result { + Self::try_new(spec.matrix, spec.bound) + } +} + impl ConsecutiveOnesMatrixAugmentation { pub fn new(matrix: Vec>, bound: i64) -> Self { Self::try_new(matrix, bound).unwrap_or_else(|err| panic!("{err}")) @@ -137,7 +148,7 @@ impl Problem for ConsecutiveOnesMatrixAugmentation { } crate::declare_variants! { - default ConsecutiveOnesMatrixAugmentation => "factorial(num_cols) * num_rows * num_cols", + default ConsecutiveOnesMatrixAugmentation => "factorial(num_cols) * num_rows * num_cols" create ConsecutiveOnesMatrixAugmentationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/feasible_basis_extension.rs b/src/models/algebraic/feasible_basis_extension.rs index d80cc1ac9..bcdfbf816 100644 --- a/src/models/algebraic/feasible_basis_extension.rs +++ b/src/models/algebraic/feasible_basis_extension.rs @@ -7,7 +7,7 @@ //! //! NP-complete (Murty, 1972). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -19,11 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Given matrix A, vector a_bar, and required columns S, find a feasible basis extending S", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "m x n integer matrix A (row-major)" }, - FieldInfo { name: "rhs", type_name: "Vec", description: "Column vector a_bar of length m" }, - FieldInfo { name: "required_columns", type_name: "Vec", description: "Subset S of column indices that must be in the basis" }, - ], + fields: FeasibleBasisExtensionCreateSpec::FIELDS, } } @@ -66,6 +62,57 @@ pub struct FeasibleBasisExtension { required_columns: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct FeasibleBasisExtensionCreateSpec { + /// Integer matrix as JSON. + #[create(codec = "json")] + matrix: Vec>, + /// Right-hand side vector. + #[create(codec = "comma-separated")] + rhs: Vec, + /// Required column indices. + #[create(codec = "comma-separated")] + required_columns: Vec, +} + +impl TryFrom for FeasibleBasisExtension { + type Error = String; + fn try_from(spec: FeasibleBasisExtensionCreateSpec) -> Result { + let m = spec.matrix.len(); + let first = spec + .matrix + .first() + .ok_or("matrix must have at least one row")?; + let n = first.len(); + if spec.matrix.iter().any(|row| row.len() != n) { + return Err("all matrix rows must have the same length".into()); + } + if m >= n { + return Err("number of rows must be less than number of columns".into()); + } + if spec.rhs.len() != m { + return Err("rhs length must equal number of rows".into()); + } + if spec.required_columns.len() >= m { + return Err("required_columns length must be less than number of rows".into()); + } + let mut seen = std::collections::HashSet::new(); + for &column in &spec.required_columns { + if column >= n { + return Err(format!("required column {column} is out of bounds")); + } + if !seen.insert(column) { + return Err(format!("duplicate required column {column}")); + } + } + Ok(Self { + matrix: spec.matrix, + rhs: spec.rhs, + required_columns: spec.required_columns, + }) + } +} + impl FeasibleBasisExtension { /// Create a new FeasibleBasisExtension instance. /// @@ -322,7 +369,7 @@ impl Problem for FeasibleBasisExtension { } crate::declare_variants! { - default FeasibleBasisExtension => "2^num_columns * num_rows^3", + default FeasibleBasisExtension => "2^num_columns * num_rows^3" create FeasibleBasisExtensionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/minimum_weight_decoding.rs b/src/models/algebraic/minimum_weight_decoding.rs index a4c424200..9cc5dab83 100644 --- a/src/models/algebraic/minimum_weight_decoding.rs +++ b/src/models/algebraic/minimum_weight_decoding.rs @@ -4,7 +4,7 @@ //! vector s of length n, find a binary vector x of length m minimizing the //! Hamming weight |x| subject to Hx ≡ s (mod 2). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find minimum Hamming weight binary vector x such that Hx ≡ s (mod 2)", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "n×m binary parity-check matrix H" }, - FieldInfo { name: "target", type_name: "Vec", description: "binary syndrome vector s of length n" }, - ], + fields: MinimumWeightDecodingCreateSpec::FIELDS, } } @@ -61,6 +58,39 @@ pub struct MinimumWeightDecoding { target: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumWeightDecodingCreateSpec { + /// Binary parity-check matrix as JSON. + #[create(codec = "json")] + matrix: Vec>, + /// Binary syndrome vector. + #[create(name = "rhs", codec = "comma-separated")] + target: Vec, +} + +impl TryFrom for MinimumWeightDecoding { + type Error = String; + fn try_from(spec: MinimumWeightDecodingCreateSpec) -> Result { + let first = spec + .matrix + .first() + .ok_or("matrix must have at least one row")?; + if first.is_empty() { + return Err("matrix must have at least one column".into()); + } + if spec.matrix.iter().any(|row| row.len() != first.len()) { + return Err("all matrix rows must have the same length".into()); + } + if spec.target.len() != spec.matrix.len() { + return Err("rhs length must equal number of rows".into()); + } + Ok(Self { + matrix: spec.matrix, + target: spec.target, + }) + } +} + impl MinimumWeightDecoding { /// Create a new MinimumWeightDecoding instance. /// @@ -144,7 +174,7 @@ impl Problem for MinimumWeightDecoding { } crate::declare_variants! { - default MinimumWeightDecoding => "2^(0.0494 * num_cols)", + default MinimumWeightDecoding => "2^(0.0494 * num_cols)" create MinimumWeightDecodingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs index 5457a1837..7c33f0eb2 100644 --- a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -3,7 +3,7 @@ //! Given an n×m integer matrix A and integer vector b, find a rational vector y //! with Ay = b that minimizes the number of non-zero entries (Hamming weight). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a rational solution to Ay=b minimizing the number of non-zero entries", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "n×m integer matrix A" }, - FieldInfo { name: "rhs", type_name: "Vec", description: "right-hand side vector b of length n" }, - ], + fields: MinimumWeightSolutionCreateSpec::FIELDS, } } @@ -60,6 +57,39 @@ pub struct MinimumWeightSolutionToLinearEquations { rhs: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumWeightSolutionCreateSpec { + /// Integer matrix as JSON. + #[create(codec = "json")] + matrix: Vec>, + /// Right-hand side vector. + #[create(codec = "comma-separated")] + rhs: Vec, +} + +impl TryFrom for MinimumWeightSolutionToLinearEquations { + type Error = String; + fn try_from(spec: MinimumWeightSolutionCreateSpec) -> Result { + let first = spec + .matrix + .first() + .ok_or("matrix must have at least one row")?; + if first.is_empty() { + return Err("matrix must have at least one column".into()); + } + if spec.matrix.iter().any(|row| row.len() != first.len()) { + return Err("all matrix rows must have the same length".into()); + } + if spec.rhs.len() != spec.matrix.len() { + return Err("rhs length must equal number of rows".into()); + } + Ok(Self { + matrix: spec.matrix, + rhs: spec.rhs, + }) + } +} + impl MinimumWeightSolutionToLinearEquations { /// Create a new MinimumWeightSolutionToLinearEquations instance. /// @@ -205,7 +235,7 @@ impl Problem for MinimumWeightSolutionToLinearEquations { } crate::declare_variants! { - default MinimumWeightSolutionToLinearEquations => "2^num_variables", + default MinimumWeightSolutionToLinearEquations => "2^num_variables" create MinimumWeightSolutionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 0228c438f..73bb5e6ff 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -2,7 +2,7 @@ //! //! QUBO minimizes a quadratic function over binary variables. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; use serde::{Deserialize, Serialize}; @@ -15,10 +15,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "f64", &["f64"])], module_path: module_path!(), description: "Minimize quadratic unconstrained binary objective", - fields: &[ - FieldInfo { name: "num_vars", type_name: "usize", description: "Number of binary variables" }, - FieldInfo { name: "matrix", type_name: "Vec>", description: "Upper-triangular Q matrix" }, - ], + fields: QuboCreateSpec::FIELDS, } } @@ -68,6 +65,24 @@ pub struct QUBO { matrix: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct QuboCreateSpec { + /// Q matrix; the number of variables is its row count. + #[create(codec = "semicolon-separated")] + matrix: Vec>, +} + +impl TryFrom for QUBO { + type Error = String; + + fn try_from(spec: QuboCreateSpec) -> Result { + Ok(Self { + num_vars: spec.matrix.len(), + matrix: spec.matrix, + }) + } +} + impl QUBO { /// Create a QUBO problem from a full matrix. /// @@ -181,7 +196,7 @@ where } crate::declare_variants! { - default QUBO => "2^num_vars", + default QUBO => "2^num_vars" create QuboCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/algebraic/sparse_matrix_compression.rs b/src/models/algebraic/sparse_matrix_compression.rs index 7f4f5e9c5..186b8a1a6 100644 --- a/src/models/algebraic/sparse_matrix_compression.rs +++ b/src/models/algebraic/sparse_matrix_compression.rs @@ -4,7 +4,7 @@ //! whether the rows can be overlaid into a storage vector of length `n + K` //! by assigning each row a shift in `{1, ..., K}` without collisions. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Overlay binary-matrix rows into a short storage vector by shifting each row without collisions", - fields: &[ - FieldInfo { name: "matrix", type_name: "Vec>", description: "m x n binary matrix A" }, - FieldInfo { name: "bound_k", type_name: "usize", description: "Maximum shift range K" }, - ], + fields: SparseMatrixCompressionCreateSpec::FIELDS, } } @@ -35,6 +32,28 @@ pub struct SparseMatrixCompression { bound_k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SparseMatrixCompressionCreateSpec { + /// m x n binary matrix A. + matrix: Vec>, + /// Maximum shift range K. + bound_k: usize, +} + +impl TryFrom for SparseMatrixCompression { + type Error = String; + fn try_from(spec: SparseMatrixCompressionCreateSpec) -> Result { + if spec.bound_k == 0 { + return Err("bound_k must be positive".to_string()); + } + let columns = spec.matrix.first().map_or(0, Vec::len); + if spec.matrix.iter().any(|row| row.len() != columns) { + return Err("all matrix rows must have the same length".to_string()); + } + Ok(Self::new(spec.matrix, spec.bound_k)) + } +} + impl SparseMatrixCompression { /// Create a new SparseMatrixCompression instance. /// @@ -135,7 +154,7 @@ impl Problem for SparseMatrixCompression { } crate::declare_variants! { - default SparseMatrixCompression => "(bound_k ^ num_rows) * num_rows * num_cols", + default SparseMatrixCompression => "(bound_k ^ num_rows) * num_rows * num_cols" create SparseMatrixCompressionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/decision.rs b/src/models/decision.rs index 412161f98..3f26353cc 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -4,7 +4,7 @@ use crate::rules::{AggregateReductionResult, ReduceTo, ReduceToAggregate, Reduct use crate::traits::Problem; use crate::types::{OptimizationValue, Or}; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; /// Metadata for concrete optimization problems that expose a decision wrapper. pub trait DecisionProblemMeta: Problem @@ -46,8 +46,17 @@ macro_rules! register_decision_variant { fields: [$($field:expr),* $(,)?], size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] ) => { + impl $crate::registry::CreateSpec + for $crate::models::decision::DecisionCreateSpec<$inner> + { + const FIELDS: &'static [$crate::registry::FieldInfo] = &[$($field),*]; + const INPUTS: &'static [$crate::registry::CreateInputInfo] = &[ + $($crate::registry::CreateInputInfo::from_field($field)),* + ]; + } + $crate::declare_variants! { - default $crate::models::decision::Decision<$inner> => $complexity, + default $crate::models::decision::Decision<$inner> => $complexity create $crate::models::decision::DecisionCreateSpec<$inner>, } $crate::inventory::submit! { @@ -145,6 +154,54 @@ macro_rules! register_decision_variant { }; } +/// Flat construction DTO used by [`register_decision_variant!`]. +/// +/// Persisted decision problems remain `{ "inner": ..., "bound": ... }`, while +/// construction inputs expose the inner problem's fields beside `bound`. +#[doc(hidden)] +pub struct DecisionCreateSpec

+where + P: Problem, + P::Value: OptimizationValue, +{ + inner: P, + bound: ::Inner, +} + +impl<'de, P> Deserialize<'de> for DecisionCreateSpec

+where + P: Problem + DeserializeOwned, + P::Value: OptimizationValue, + ::Inner: DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + let mut inputs = value.as_object().cloned().ok_or_else(|| { + serde::de::Error::custom("decision construction inputs must be an object") + })?; + let bound = inputs + .remove("bound") + .ok_or_else(|| serde::de::Error::missing_field("bound"))?; + let inner = serde_json::from_value(serde_json::Value::Object(inputs)) + .map_err(serde::de::Error::custom)?; + let bound = serde_json::from_value(bound).map_err(serde::de::Error::custom)?; + Ok(Self { inner, bound }) + } +} + +impl

From> for Decision

+where + P: Problem, + P::Value: OptimizationValue, +{ + fn from(spec: DecisionCreateSpec

) -> Self { + Self::new(spec.inner, spec.bound) + } +} + /// Decision version of an optimization problem with a fixed objective bound. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 4bb5f4935..acec604d4 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -5,7 +5,7 @@ //! DAG, each group's total vertex weight is bounded, and the total //! inter-partition arc cost is bounded. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,13 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Partition a directed graph into bounded-weight groups with an acyclic quotient graph and bounded inter-partition cost", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "arc_costs", type_name: "Vec", description: "Arc costs c(a) for each arc a in A, matching graph.arcs() order" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Maximum total vertex weight B for each partition" }, - FieldInfo { name: "cost_bound", type_name: "W::Sum", description: "Maximum total inter-partition arc cost K" }, - ], + fields: AcyclicPartitionCreateSpec::FIELDS, } } @@ -50,6 +44,68 @@ pub struct AcyclicPartition { cost_bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct AcyclicPartitionCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(name = "arc_costs", codec = "comma-separated")] + arc_weights: Option>, + weight_bound: i64, + cost_bound: i64, +} + +impl TryFrom for AcyclicPartition { + type Error = String; + + fn try_from(spec: AcyclicPartitionCreateSpec) -> Result { + if spec.arcs.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty arc list".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let vertex_weights = spec.weights.unwrap_or_else(|| vec![1; num_vertices]); + if vertex_weights.len() != num_vertices { + return Err(format!( + "weights has length {}, expected {num_vertices}", + vertex_weights.len() + )); + } + let arc_costs = spec + .arc_weights + .unwrap_or_else(|| vec![1; graph.num_arcs()]); + if arc_costs.len() != graph.num_arcs() { + return Err(format!( + "arc_weights has length {}, expected {}", + arc_costs.len(), + graph.num_arcs() + )); + } + Ok(Self::new( + graph, + vertex_weights, + arc_costs, + spec.weight_bound, + spec.cost_bound, + )) + } +} + impl AcyclicPartition { /// Create a new Acyclic Partition instance. pub fn new( @@ -237,7 +293,7 @@ fn is_valid_acyclic_partition( } crate::declare_variants! { - default AcyclicPartition => "num_vertices^num_vertices", + default AcyclicPartition => "num_vertices^num_vertices" create AcyclicPartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/balanced_complete_bipartite_subgraph.rs b/src/models/graph/balanced_complete_bipartite_subgraph.rs index fe2ce502f..9f7bd8122 100644 --- a/src/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,4 +1,4 @@ -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -12,10 +12,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Decide whether a bipartite graph contains a K_{k,k} subgraph", - fields: &[ - FieldInfo { name: "graph", type_name: "BipartiteGraph", description: "The bipartite graph G = (A, B, E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Balanced biclique size" }, - ], + fields: BalancedCompleteBipartiteSubgraphCreateSpec::FIELDS, } } @@ -28,6 +25,44 @@ pub struct BalancedCompleteBipartiteSubgraph { edge_lookup: HashSet<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BalancedCompleteBipartiteSubgraphCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Balanced biclique size. + k: usize, +} + +impl TryFrom for BalancedCompleteBipartiteSubgraph { + type Error = String; + + fn try_from(spec: BalancedCompleteBipartiteSubgraphCreateSpec) -> Result { + for (index, &(left, right)) in spec.biedges.iter().enumerate() { + if left >= spec.left { + return Err(format!( + "biedges[{index}] left vertex {left} is out of bounds for left partition size {}", + spec.left + )); + } + if right >= spec.right { + return Err(format!( + "biedges[{index}] right vertex {right} is out of bounds for right partition size {}", + spec.right + )); + } + } + Ok(Self::new( + BipartiteGraph::new(spec.left, spec.right, spec.biedges), + spec.k, + )) + } +} + impl BalancedCompleteBipartiteSubgraph { pub fn new(graph: BipartiteGraph, k: usize) -> Self { let edge_lookup = Self::build_edge_lookup(&graph); @@ -144,7 +179,7 @@ impl From for BalancedCompleteBipartiteSu } crate::declare_variants! { - default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices", + default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices" create BalancedCompleteBipartiteSubgraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biclique_cover.rs b/src/models/graph/biclique_cover.rs index ef94bad62..63c36f754 100644 --- a/src/models/graph/biclique_cover.rs +++ b/src/models/graph/biclique_cover.rs @@ -13,7 +13,7 @@ //! matrix of `G` (Monson, Pullman, Rees 1995), matching exact Boolean //! Matrix Factorization. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use crate::types::Min; @@ -28,12 +28,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Cover bipartite edges with k bicliques", - fields: &[ - FieldInfo { name: "left_size", type_name: "usize", description: "Vertices in left partition" }, - FieldInfo { name: "right_size", type_name: "usize", description: "Vertices in right partition" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Bipartite edges" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of bicliques" }, - ], + fields: BicliqueCoverCreateSpec::FIELDS, } } @@ -70,6 +65,43 @@ pub struct BicliqueCover { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BicliqueCoverCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Number of bicliques available to cover the edges. + k: usize, +} + +impl TryFrom for BicliqueCover { + type Error = String; + + fn try_from(spec: BicliqueCoverCreateSpec) -> Result { + for (edge_index, &(left_vertex, right_vertex)) in spec.biedges.iter().enumerate() { + if left_vertex >= spec.left { + return Err(format!( + "biedges[{edge_index}] left vertex {left_vertex} is out of bounds for left partition size {}", + spec.left + )); + } + if right_vertex >= spec.right { + return Err(format!( + "biedges[{edge_index}] right vertex {right_vertex} is out of bounds for right partition size {}", + spec.right + )); + } + } + + let graph = BipartiteGraph::new(spec.left, spec.right, spec.biedges); + Ok(Self::new(graph, spec.k)) + } +} + impl BicliqueCover { /// Create a new Biclique Cover problem. /// @@ -290,7 +322,7 @@ impl Problem for BicliqueCover { } crate::declare_variants! { - default BicliqueCover => "2^(num_vertices * rank)", + default BicliqueCover => "2^(num_vertices * rank)" create BicliqueCoverCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index d923c2ce9..2b4d5949f 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -4,7 +4,7 @@ //! adding some subset of the potential edges can make the graph biconnected //! without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,11 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Add weighted potential edges to make a graph biconnected within budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "potential_weights", type_name: "Vec<(usize, usize, W)>", description: "Potential edges with augmentation weights" }, - FieldInfo { name: "budget", type_name: "W::Sum", description: "Maximum total augmentation weight B" }, - ], + fields: BiconnectivityAugmentationCreateSpec::FIELDS, } } @@ -54,6 +50,65 @@ where budget: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BiconnectivityAugmentationCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + potential_weights: Vec<(usize, usize, i32)>, + budget: i64, +} + +impl TryFrom + for BiconnectivityAugmentation +{ + type Error = String; + fn try_from(spec: BiconnectivityAugmentationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + let graph = SimpleGraph::new(count, spec.graph); + let mut seen = BTreeSet::new(); + for &(u, v, _) in &spec.potential_weights { + if u >= count || v >= count { + return Err("potential edge endpoint is out of bounds".into()); + } + if u == v { + return Err("potential edge is a self-loop".into()); + } + let edge = normalize_edge(u, v); + if graph.has_edge(edge.0, edge.1) { + return Err("potential edge already exists in graph".into()); + } + if !seen.insert(edge) { + return Err("duplicate potential edge".into()); + } + } + Ok(Self { + graph, + potential_weights: spec.potential_weights, + budget: spec.budget, + }) + } +} + impl BiconnectivityAugmentation { /// Create a new biconnectivity augmentation instance. /// @@ -255,7 +310,7 @@ fn is_biconnected(graph: &G) -> bool { } crate::declare_variants! { - default BiconnectivityAugmentation => "2^num_potential_edges", + default BiconnectivityAugmentation => "2^num_potential_edges" create BiconnectivityAugmentationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 3badad9cb..1dfbd5824 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle //! minimizing the maximum selected edge weight. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z" }, - ], + fields: BottleneckTravelingSalesmanCreateSpec::FIELDS, } } @@ -31,6 +28,62 @@ pub struct BottleneckTravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BottleneckTravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for BottleneckTravelingSalesman { + type Error = String; + + fn try_from(spec: BottleneckTravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BottleneckTravelingSalesman { /// Create a BottleneckTravelingSalesman problem from a graph with edge weights. pub fn new(graph: SimpleGraph, edge_weights: Vec) -> Self { @@ -157,7 +210,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "num_vertices^2 * 2^num_vertices", + default BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 32f229a1a..c9d0c09d3 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -4,7 +4,7 @@ //! weighted graph can be partitioned into at most `K` connected components, each //! of total weight at most `B`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,12 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Partition vertices into at most K connected components, each of total weight at most B", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "max_components", type_name: "usize", description: "Upper bound K on the number of connected components" }, - FieldInfo { name: "max_weight", type_name: "W::Sum", description: "Upper bound B on the total weight of each component" }, - ], + fields: BoundedComponentSpanningForestCreateSpec::FIELDS, } } @@ -50,6 +45,44 @@ pub struct BoundedComponentSpanningForest { max_weight: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedComponentSpanningForestCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w(v) for each vertex v in V. + weights: Vec, + /// Upper bound K on the number of connected components. + k: usize, + /// Upper bound B on the total weight of each component. + max_weight: i64, +} + +impl TryFrom + for BoundedComponentSpanningForest +{ + type Error = String; + + fn try_from(spec: BoundedComponentSpanningForestCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.weights.iter().any(|&weight| weight < 0) { + return Err("weights must be nonnegative".to_string()); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + if spec.max_weight <= 0 { + return Err("max_weight must be positive".to_string()); + } + Ok(Self::new(spec.graph, spec.weights, spec.k, spec.max_weight)) + } +} + impl BoundedComponentSpanningForest { /// Create a new bounded-component spanning forest instance. pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { @@ -230,7 +263,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "3^num_vertices", + default BoundedComponentSpanningForest => "3^num_vertices" create BoundedComponentSpanningForestCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 217e203b6..42b5561a8 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -4,7 +4,7 @@ //! bound D, determine whether G has a spanning tree with total weight at most B //! and diameter (longest shortest path in edges) at most D. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -24,12 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Does G have a spanning tree with total weight <= B and diameter <= D?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound B on total tree weight" }, - FieldInfo { name: "diameter_bound", type_name: "usize", description: "Upper bound D on tree diameter (in edges)" }, - ], + fields: BoundedDiameterSpanningTreeCreateSpec::FIELDS, } } @@ -80,6 +75,78 @@ pub struct BoundedDiameterSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedDiameterSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + weight_bound: i64, + diameter_bound: usize, +} + +impl TryFrom + for BoundedDiameterSpanningTree +{ + type Error = String; + + fn try_from(spec: BoundedDiameterSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + if edge_weights.iter().any(|&weight| weight <= 0) { + return Err("edge_weights must be positive".to_string()); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + if spec.diameter_bound == 0 { + return Err("diameter_bound must be at least 1".to_string()); + } + Ok(Self::new( + graph, + edge_weights, + spec.weight_bound, + spec.diameter_bound, + )) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BoundedDiameterSpanningTree { /// Create a new Bounded Diameter Spanning Tree instance. /// @@ -280,7 +347,7 @@ where } crate::declare_variants! { - default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices", + default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices" create BoundedDiameterSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index d565fa123..b48a1f1af 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -3,7 +3,7 @@ //! The problem asks whether an undirected graph contains pairwise //! vertex-disjoint paths connecting a prescribed collection of terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find pairwise vertex-disjoint paths connecting given terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminal_pairs", type_name: "Vec<(usize, usize)>", description: "Disjoint terminal pairs (s_i, t_i)" }, - ], + fields: DisjointConnectingPathsCreateSpec::FIELDS, } } @@ -39,6 +36,62 @@ pub struct DisjointConnectingPaths { terminal_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct DisjointConnectingPathsCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "edge-list")] + terminal_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for DisjointConnectingPaths { + type Error = String; + fn try_from(spec: DisjointConnectingPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.terminal_pairs.is_empty() { + return Err("terminal_pairs must contain at least one pair".into()); + } + let mut used = vec![false; count]; + for &(source, sink) in &spec.terminal_pairs { + if source >= count || sink >= count { + return Err("terminal pair endpoint is out of bounds".into()); + } + if source == sink { + return Err("terminal pair endpoints must be distinct".into()); + } + if used[source] || used[sink] { + return Err("terminal vertices must be pairwise disjoint".into()); + } + used[source] = true; + used[sink] = true; + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + terminal_pairs: spec.terminal_pairs, + }) + } +} + impl DisjointConnectingPaths { /// Create a new Disjoint Connecting Paths instance. /// @@ -243,7 +296,7 @@ fn is_valid_disjoint_connecting_paths( } crate::declare_variants! { - default DisjointConnectingPaths => "2^num_edges", + default DisjointConnectingPaths => "2^num_edges" create DisjointConnectingPathsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index ef7252aae..156bf995a 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Determine whether Player 1 has a forced blue path between two terminals", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The source terminal s" }, - FieldInfo { name: "target", type_name: "usize", description: "The target terminal t" }, - ], + fields: GeneralizedHexCreateSpec::FIELDS, } } @@ -43,6 +39,40 @@ pub struct GeneralizedHex { target: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GeneralizedHexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// The source terminal s. + source: usize, + /// The target terminal t. + sink: usize, +} + +impl TryFrom for GeneralizedHex { + type Error = String; + + fn try_from(spec: GeneralizedHexCreateSpec) -> Result { + let num_vertices = spec.graph.num_vertices(); + if spec.source >= num_vertices { + return Err(format!( + "source {} is outside graph with {num_vertices} vertices", + spec.source + )); + } + if spec.sink >= num_vertices { + return Err(format!( + "sink {} is outside graph with {num_vertices} vertices", + spec.sink + )); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + Ok(Self::new(spec.graph, spec.source, spec.sink)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ClaimState { Unclaimed, @@ -264,7 +294,7 @@ where } crate::declare_variants! { - default GeneralizedHex => "3^num_playable_vertices", + default GeneralizedHex => "3^num_playable_vertices" create GeneralizedHexCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index 893cb1da3..70e9f5eb4 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -3,7 +3,7 @@ //! Given a directed graph with overlapping bundle-capacity constraints on arcs, //! determine whether an integral flow can deliver a required amount to the sink. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a directed graph with overlapping bundle capacities", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G=(V,A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "bundles", type_name: "Vec>", description: "Bundles of arc indices covering A" }, - FieldInfo { name: "bundle_capacities", type_name: "Vec", description: "Capacity c_j for each bundle I_j" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowBundlesCreateSpec::FIELDS, } } @@ -46,6 +39,92 @@ pub struct IntegralFlowBundles { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowBundlesCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "semicolon-separated")] + bundles: Vec>, + #[create(codec = "comma-separated")] + bundle_capacities: Vec, + source: usize, + sink: usize, + requirement: u64, +} + +impl TryFrom for IntegralFlowBundles { + type Error = String; + fn try_from(spec: IntegralFlowBundlesCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + if spec.bundles.len() != spec.bundle_capacities.len() { + return Err("bundles length must match bundle_capacities length".into()); + } + if spec.requirement == 0 { + return Err("requirement must be positive".into()); + } + let mut covered = vec![false; spec.arcs.len()]; + let mut upper = vec![u64::MAX; spec.arcs.len()]; + for (i, (bundle, &capacity)) in spec.bundles.iter().zip(&spec.bundle_capacities).enumerate() + { + if capacity == 0 { + return Err(format!("bundle capacity {i} must be positive")); + } + let mut seen = BTreeSet::new(); + for &arc in bundle { + if arc >= spec.arcs.len() { + return Err(format!("bundle {i} arc is out of range")); + } + if !seen.insert(arc) { + return Err(format!("bundle {i} contains duplicate arc")); + } + covered[arc] = true; + upper[arc] = upper[arc].min(capacity); + } + } + for (arc, &is_covered) in covered.iter().enumerate() { + if !is_covered { + return Err(format!("arc {arc} must belong to a bundle")); + } + if usize::try_from(upper[arc]) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err(format!("arc {arc} upper bound is too large")); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + bundles: spec.bundles, + bundle_capacities: spec.bundle_capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowBundles { /// Create a new Integral Flow with Bundles instance. pub fn new( @@ -267,7 +346,7 @@ impl Problem for IntegralFlowBundles { } crate::declare_variants! { - default IntegralFlowBundles => "2^num_arcs", + default IntegralFlowBundles => "2^num_arcs" create IntegralFlowBundlesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 0798cd834..29a0c5001 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -4,7 +4,7 @@ //! that must carry equal flow, determine whether an integral flow meeting the //! required sink inflow exists. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility with arc-pair equality constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - FieldInfo { name: "homologous_pairs", type_name: "Vec<(usize, usize)>", description: "Arc-index pairs (a, a') with f(a) = f(a')" }, - ], + fields: IntegralFlowHomologousArcsCreateSpec::FIELDS, } } @@ -51,6 +44,70 @@ pub struct IntegralFlowHomologousArcs { homologous_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowHomologousArcsCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Option>, + source: usize, + sink: usize, + requirement: u64, + #[create(codec = "equality-pair-list")] + homologous_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for IntegralFlowHomologousArcs { + type Error = String; + fn try_from(spec: IntegralFlowHomologousArcsCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + if capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + for &(a, b) in &spec.homologous_pairs { + if a >= spec.arcs.len() || b >= spec.arcs.len() { + return Err("homologous pair arc index is out of range".into()); + } + } + for &c in &capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + capacities, + source: spec.source, + sink: spec.sink, + requirement: spec.requirement, + homologous_pairs: spec.homologous_pairs, + }) + } +} + impl IntegralFlowHomologousArcs { pub fn new( graph: DirectedGraph, @@ -208,7 +265,7 @@ impl Problem for IntegralFlowHomologousArcs { } crate::declare_variants! { - default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs", + default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs" create IntegralFlowHomologousArcsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index d620d4b24..f8ed72103 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -4,7 +4,7 @@ //! non-terminals, and a sink demand, determine whether there exists an //! integral flow satisfying multiplier-scaled conservation. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a directed graph with multiplier-scaled conservation at non-terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "multipliers", type_name: "Vec", description: "Vertex multipliers h(v) in vertex order; source/sink entries are ignored" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Arc capacities c(a) in graph arc order" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowWithMultipliersCreateSpec::FIELDS, } } @@ -45,6 +38,75 @@ pub struct IntegralFlowWithMultipliers { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowWithMultipliersCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Vec, + source: usize, + sink: usize, + #[create(codec = "comma-separated")] + multipliers: Vec, + requirement: u64, +} + +impl TryFrom for IntegralFlowWithMultipliers { + type Error = String; + fn try_from(spec: IntegralFlowWithMultipliersCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.multipliers.len() != count { + return Err("multipliers length must match num_vertices".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + for (v, &m) in spec.multipliers.iter().enumerate() { + if v != spec.source && v != spec.sink && m == 0 { + return Err("non-terminal multipliers must be positive".into()); + } + } + for &c in &spec.capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + multipliers: spec.multipliers, + capacities: spec.capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowWithMultipliers { pub fn new( graph: DirectedGraph, @@ -214,7 +276,7 @@ impl Problem for IntegralFlowWithMultipliers { } crate::declare_variants! { - default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs", + default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs" create IntegralFlowWithMultipliersCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 94fa7e788..71c8d2e78 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -3,7 +3,7 @@ //! KClique is the decision version of Clique: determine whether a graph //! contains a clique of size at least `k`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"])], module_path: module_path!(), description: "Determine whether a graph contains a clique of size at least k", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Minimum clique size threshold" }, - ], + fields: KCliqueCreateSpec::FIELDS, } } @@ -34,6 +31,50 @@ pub struct KClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KCliqueCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + k: usize, +} + +impl TryFrom for KClique { + type Error = String; + fn try_from(spec: KCliqueCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.k == 0 { + return Err("k must be positive".into()); + } + if spec.k > count { + return Err("k must be <= graph num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + k: spec.k, + }) + } +} + impl KClique { /// Create a new k-Clique problem instance. pub fn new(graph: G, k: usize) -> Self { @@ -136,7 +177,7 @@ fn is_kclique_config(graph: &G, config: &[usize], k: usize) -> bool { } crate::declare_variants! { - default KClique => "1.1996^num_vertices", + default KClique => "1.1996^num_vertices" create KCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9e810dfc9..cbb91175a 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -3,7 +3,7 @@ //! The K-Coloring problem asks whether a graph can be colored with K colors //! such that no two adjacent vertices have the same color. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::{KValue, VariantParam, K2, K3, K4, K5, KN}; @@ -20,9 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find valid k-coloring of a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - ], + fields: RuntimeKColoringCreateSpec::FIELDS, } } @@ -68,6 +66,81 @@ pub struct KColoring { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct FixedKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuntimeKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Runtime color count. + k: usize, +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = num_vertices.unwrap_or(inferred); + if count < inferred { + return Err(format!( + "num_vertices {count} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(count, edges)) +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: FixedKColoringCreateSpec) -> Result { + let num_colors = K::K.ok_or("runtime KColoring requires k")?; + Ok(Self { + graph: simple_graph_from_create(spec.graph, spec.num_vertices)?, + num_colors, + _phantom: std::marker::PhantomData, + }) + } +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: RuntimeKColoringCreateSpec) -> Result { + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::with_k( + simple_graph_from_create(spec.graph, spec.num_vertices)?, + spec.k, + )) + } +} + fn default_num_colors() -> usize { K::K.unwrap_or(0) } @@ -201,12 +274,12 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", - KColoring => "num_vertices + num_edges", - KColoring => "1.3289^num_vertices", - KColoring => "1.7159^num_vertices", + default KColoring => "2^num_vertices" create RuntimeKColoringCreateSpec, + KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec, + KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec, + KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec, // Best known: O*((2-ε)^n) for some ε > 0 (Zamir 2021), concrete ε unknown - KColoring => "2^num_vertices", + KColoring => "2^num_vertices" create FixedKColoringCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index f26c26fcb..d008f6b61 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -3,7 +3,7 @@ //! Given a weighted graph, determine whether it contains `k` distinct spanning //! trees whose total weights are all at most a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -19,12 +19,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], module_path: module_path!(), description: "Do there exist k distinct spanning trees with total weight at most B?", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w(e) for each edge in E" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of distinct spanning trees required" }, - FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on each spanning tree weight" }, - ], + fields: KthBestSpanningTreeCreateSpec::FIELDS, } } @@ -46,6 +41,65 @@ pub struct KthBestSpanningTree { bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthBestSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + bound: i64, +} + +impl TryFrom for KthBestSpanningTree { + type Error = String; + + fn try_from(spec: KthBestSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + weights.len(), + graph.num_edges() + )); + } + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::new(graph, weights, spec.k, spec.bound)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl KthBestSpanningTree { /// Create a new KthBestSpanningTree instance. /// @@ -240,7 +294,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^(num_edges * k)", + default KthBestSpanningTree => "2^(num_edges * k)" create KthBestSpanningTreeCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 93e97073c..0fd0afd71 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -3,7 +3,7 @@ //! The problem maximizes the number of internally vertex-disjoint `s-t` paths, //! each using at most `K` edges, over up to `max_paths` path slots. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Max; @@ -20,13 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Maximize the number of internally vertex-disjoint s-t paths of length at most K", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The shared source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "The shared sink vertex t" }, - FieldInfo { name: "max_paths", type_name: "usize", description: "Upper bound on the number of path slots" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum path length K in edges" }, - ], + fields: LengthBoundedDisjointPathsCreateSpec::FIELDS, } } @@ -48,6 +42,72 @@ pub struct LengthBoundedDisjointPaths { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LengthBoundedDisjointPathsCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Shared source vertex. + source: usize, + /// Shared sink vertex. + sink: usize, + /// Maximum path length in edges. + max_length: usize, +} + +impl TryFrom for LengthBoundedDisjointPaths { + type Error = String; + + fn try_from(spec: LengthBoundedDisjointPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + if spec.source >= num_vertices || spec.sink >= num_vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.max_length == 0 { + return Err("max_length must be positive".to_string()); + } + + let graph = SimpleGraph::new(num_vertices, spec.graph); + let max_paths = graph + .neighbors(spec.source) + .len() + .min(graph.neighbors(spec.sink).len()); + Ok(Self { + graph, + source: spec.source, + sink: spec.sink, + max_paths, + max_length: spec.max_length, + }) + } +} + impl LengthBoundedDisjointPaths { /// Create a new Length-Bounded Disjoint Paths instance. /// @@ -301,7 +361,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^(max_paths * num_vertices)", + default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)" create LengthBoundedDisjointPathsCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 735d11d00..1c9988599 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -3,7 +3,7 @@ //! The Longest Circuit problem asks for a simple circuit in a graph //! that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple circuit in a graph that maximizes total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> Z_(> 0)" }, - ], + fields: LongestCircuitCreateSpec::FIELDS, } } @@ -48,6 +45,65 @@ pub struct LongestCircuit { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCircuitCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for LongestCircuit { + type Error = String; + + fn try_from(spec: LongestCircuitCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if edge_lengths.iter().any(|&length| length <= 0) { + return Err("edge_weights must be positive".to_string()); + } + Ok(Self::new(graph, edge_lengths)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl LongestCircuit { /// Create a new LongestCircuit instance. /// @@ -256,7 +312,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices * num_vertices^2", + default LongestCircuit => "2^num_vertices * num_vertices^2" create LongestCircuitCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index fd70dbeb3..74fd5f9a1 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -3,7 +3,7 @@ //! The Longest Path problem asks for a simple path between two distinguished //! vertices that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -22,12 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple s-t path of maximum total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - ], + fields: LongestPathI32CreateSpec::FIELDS, } } @@ -53,6 +48,63 @@ pub struct LongestPath { target_vertex: usize, } +macro_rules! longest_path_create_spec { + ($name:ident,$weight:ty) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_lengths: Vec<$weight>, + source_vertex: usize, + target_vertex: usize, + } + impl TryFrom<$name> for LongestPath { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.edge_lengths.len() != spec.graph.len() { + return Err("edge_lengths length must match graph edge count".into()); + } + if spec.edge_lengths.iter().any(|v| v.to_sum() <= 0) { + return Err("edge lengths must be positive".into()); + } + if spec.source_vertex >= count || spec.target_vertex >= count { + return Err("source_vertex and target_vertex must be valid vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + edge_lengths: spec.edge_lengths, + source_vertex: spec.source_vertex, + target_vertex: spec.target_vertex, + }) + } + } + }; +} +longest_path_create_spec!(LongestPathI32CreateSpec, i32); +longest_path_create_spec!(LongestPathOneCreateSpec, One); + impl LongestPath { fn assert_positive_edge_lengths(edge_lengths: &[W]) { let zero = W::Sum::zero(); @@ -253,8 +305,8 @@ fn is_simple_st_path( } crate::declare_variants! { - default LongestPath => "num_vertices * 2^num_vertices", - LongestPath => "num_vertices * 2^num_vertices", + default LongestPath => "num_vertices * 2^num_vertices" create LongestPathI32CreateSpec, + LongestPath => "num_vertices * 2^num_vertices" create LongestPathOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0dba64bbc..edd4354ee 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -3,7 +3,7 @@ //! The Maximum Cut problem asks for a partition of vertices into two sets //! that maximizes the total weight of edges crossing the partition. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight cut in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The graph with edge weights" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaxCutI32CreateSpec::FIELDS, } } @@ -77,6 +74,67 @@ pub struct MaxCut { edge_weights: Vec, } +macro_rules! max_cut_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + } + + impl TryFrom<$name> for MaxCut { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } + } + }; +} + +max_cut_create_spec!(MaxCutI32CreateSpec, i32, 1); +max_cut_create_spec!(MaxCutOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaxCut { /// Create a MaxCut problem from a graph with specified edge weights. /// @@ -209,8 +267,8 @@ where } crate::declare_variants! { - default MaxCut => "2^(2.372 * num_vertices / 3)", - MaxCut => "2^(0.7907 * num_vertices)", + default MaxCut => "2^(2.372 * num_vertices / 3)" create MaxCutI32CreateSpec, + MaxCut => "2^(0.7907 * num_vertices)" create MaxCutOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index b08c37d4b..f157fac2c 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -3,7 +3,7 @@ //! The Maximal Independent Set problem asks for an independent set that //! cannot be extended by adding any other vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight maximal independent set", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximalISCreateSpec::FIELDS, } } @@ -63,6 +60,28 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximalISCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom for MaximalIS { + type Error = String; + fn try_from(spec: MaximalISCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximalIS { /// Create a Maximal Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -223,7 +242,7 @@ pub(crate) fn is_maximal_independent_set(graph: &G, selected: &[bool]) } crate::declare_variants! { - default MaximalIS => "3^(num_vertices / 3)", + default MaximalIS => "3^(num_vertices / 3)" create MaximalISCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 849bef38a..a507783ac 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -3,7 +3,7 @@ //! The MaximumClique problem asks for a maximum weight subset of vertices //! such that all vertices in the subset are pairwise adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight clique in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumCliqueCreateSpec::::FIELDS, } } @@ -66,6 +63,28 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCliqueCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> for MaximumClique { + type Error = String; + fn try_from(spec: MaximumCliqueCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximumClique { /// Create a MaximumClique problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -165,8 +184,8 @@ fn is_clique_config(graph: &G, config: &[usize]) -> bool { } crate::declare_variants! { - MaximumClique => "1.1996^num_vertices", - default MaximumClique => "1.1996^num_vertices", + MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec, + default MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 5c691e4e0..a22e22a8c 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -8,7 +8,7 @@ //! For k = 1 the problem degenerates to [`MaximumIndependentSet`]; for larger //! k it is the maximum (k-1)-dependent set / co-k-plex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -28,11 +28,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum-weight vertex subset whose induced subgraph has maximum degree at most k-1", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound_k", type_name: "usize", description: "Co-k-plex parameter k >= 1; selected-vertex induced degree must be at most k-1" }, - ], + fields: MaximumCoKPlexCreateSpec::::FIELDS, } } @@ -91,6 +87,36 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCoKPlexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, + /// Co-k-plex parameter k >= 1. + k: usize, +} + +impl TryFrom> + for MaximumCoKPlex +{ + type Error = String; + + fn try_from(spec: MaximumCoKPlexCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + } +} + impl MaximumCoKPlex { /// Create an instance with an explicit runtime `k`. /// @@ -224,8 +250,8 @@ fn is_co_k_plex_config(graph: &G, config: &[usize], bound_k: usize) -> } crate::declare_variants! { - default MaximumCoKPlex => "2^num_vertices", - MaximumCoKPlex => "2^num_vertices", + default MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, + MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index 9babdbebc..e4d814e4d 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -11,7 +11,7 @@ //! are allowed when `k` takes those values, with objective value 0 because no //! pair of selected vertices is induced. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -26,11 +26,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], module_path: module_path!(), description: "Select exactly k pairwise-adjacent vertices maximizing the total weight of induced clique edges", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights in graph edge order" }, - FieldInfo { name: "k", type_name: "usize", description: "Required clique size" }, - ], + fields: MaximumEdgeWeightedKCliqueCreateSpec::::FIELDS, } } @@ -77,6 +73,38 @@ pub struct MaximumEdgeWeightedKClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumEdgeWeightedKCliqueCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Required clique size. + k: usize, +} +impl TryFrom> for MaximumEdgeWeightedKClique +where + W: WeightElement + From, +{ + type Error = String; + fn try_from(spec: MaximumEdgeWeightedKCliqueCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if spec.k > spec.graph.num_vertices() { + return Err("k must not exceed the number of vertices".to_string()); + } + Ok(Self::new(spec.graph, edge_weights, spec.k)) + } +} + impl MaximumEdgeWeightedKClique { /// Create a new MaximumEdgeWeightedKClique instance. /// @@ -191,8 +219,8 @@ fn is_k_clique_config(graph: &SimpleGraph, config: &[usize], k: usize) -> bool { } crate::declare_variants! { - default MaximumEdgeWeightedKClique => "2^num_vertices", - MaximumEdgeWeightedKClique => "2^num_vertices", + default MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, + MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 9f7e72a08..9bf1b69cb 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -3,7 +3,7 @@ //! The Independent Set problem asks for a maximum weight subset of vertices //! such that no two vertices in the subset are adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight independent set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumIndependentSetSimpleOneCreateSpec::FIELDS, } } @@ -66,6 +63,138 @@ pub struct MaximumIndependentSet { weights: Vec, } +macro_rules! simple_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let weights = spec.weights.unwrap_or_else(|| vec![$one; count]); + if weights.len() != count { + return Err("weights length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + weights, + }) + } + } + }; +} +simple_mis_spec!(MaximumIndependentSetSimpleOneCreateSpec, One, One); +simple_mis_spec!(MaximumIndependentSetSimpleI32CreateSpec, i32, 1_i32); + +macro_rules! grid_mis_spec { + ($name:ident,$graph:ty,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(i32, i32)>, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet<$graph, $weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: <$graph>::new(spec.positions), + weights, + }) + } + } + }; +} +grid_mis_spec!( + MaximumIndependentSetKingsOneCreateSpec, + KingsSubgraph, + One, + One +); +grid_mis_spec!( + MaximumIndependentSetKingsI32CreateSpec, + KingsSubgraph, + i32, + 1_i32 +); +grid_mis_spec!( + MaximumIndependentSetTriangularI32CreateSpec, + TriangularSubgraph, + i32, + 1_i32 +); + +macro_rules! unit_disk_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(f64, f64)>, + radius: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + let radius = spec.radius.unwrap_or(1.0); + if !radius.is_finite() || radius < 0.0 { + return Err("radius must be finite and nonnegative".into()); + } + if spec + .positions + .iter() + .any(|&(x, y)| !x.is_finite() || !y.is_finite()) + { + return Err("positions must be finite".into()); + } + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: UnitDiskGraph::new(spec.positions, radius), + weights, + }) + } + } + }; +} +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskOneCreateSpec, One, One); +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskI32CreateSpec, i32, 1_i32); + impl MaximumIndependentSet { /// Create an Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -154,13 +283,13 @@ fn is_independent_set_config(graph: &G, config: &[usize]) -> bool { } crate::declare_variants! { - MaximumIndependentSet => "1.1996^num_vertices", - default MaximumIndependentSet => "1.1996^num_vertices", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", + MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleI32CreateSpec, + default MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleOneCreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsOneCreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetTriangularI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskOneCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MaximumIndependentSet diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index f2c3d0719..d648fe2ac 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -3,7 +3,7 @@ //! The Maximum Matching problem asks for a maximum weight set of edges //! such that no two edges share a vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight matching in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaximumMatchingCreateSpec::FIELDS, } } @@ -66,6 +63,62 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumMatchingCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for MaximumMatching { + type Error = String; + + fn try_from(spec: MaximumMatchingCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaximumMatching { /// Create a MaximumMatching problem from a graph with given edge weights. /// @@ -214,7 +267,7 @@ where } crate::declare_variants! { - default MaximumMatching => "num_vertices^3", + default MaximumMatching => "num_vertices^3" create MaximumMatchingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 5bf60ef53..a270a6cb9 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -3,10 +3,10 @@ //! The vertex p-center problem asks for K centers on vertices of a graph that //! minimize the maximum weighted distance from any vertex to its nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, WeightElement}; +use crate::types::{Min, One, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -21,12 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find K centers minimizing the maximum weighted distance from any vertex to its nearest center (vertex p-center)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinMaxMulticenterI32CreateSpec::FIELDS, } } @@ -69,6 +64,96 @@ pub struct MinMaxMulticenter { k: usize, } +macro_rules! min_max_multicenter_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + } + + impl TryFrom<$name> for MinMaxMulticenter { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + let zero = <$weight as WeightElement>::Sum::zero(); + if vertex_weights + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("weights must be non-negative".to_string()); + } + if edge_lengths + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("edge_weights must be non-negative".to_string()); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } + } + }; +} + +min_max_multicenter_create_spec!(MinMaxMulticenterI32CreateSpec, i32, 1); +min_max_multicenter_create_spec!(MinMaxMulticenterOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinMaxMulticenter { /// Create a MinMaxMulticenter problem. /// @@ -272,8 +357,8 @@ where } crate::declare_variants! { - default MinMaxMulticenter => "1.4969^num_vertices", - MinMaxMulticenter => "1.4969^num_vertices", + default MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterI32CreateSpec, + MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 04762cf3d..3de793ec8 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -8,7 +8,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -24,13 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight spanning tree with subtree capacity constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "root", type_name: "usize", description: "Root vertex" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Vertex requirements r: V -> R (root has 0)" }, - FieldInfo { name: "capacity", type_name: "W::Sum", description: "Subtree capacity bound" }, - ], + fields: MinimumCapacitatedSpanningTreeCreateSpec::FIELDS, } } @@ -67,6 +61,55 @@ pub struct MinimumCapacitatedSpanningTree { capacity: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCapacitatedSpanningTreeCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + weights: Option>, + /// Root vertex. + root: usize, + /// Vertex requirements. + requirements: Vec, + /// Subtree capacity bound. + capacity: i64, +} +impl TryFrom + for MinimumCapacitatedSpanningTree +{ + type Error = String; + fn try_from(spec: MinimumCapacitatedSpanningTreeCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + let weights = spec.weights.unwrap_or_else(|| vec![1; edges]); + if weights.len() != edges { + return Err(format!( + "weights has {} entries, expected {edges}", + weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if vertices < 2 { + return Err("graph must have at least two vertices".to_string()); + } + if spec.requirements.len() != vertices { + return Err(format!( + "requirements has {} entries, expected {vertices}", + spec.requirements.len() + )); + } + if spec.root >= vertices { + return Err("root is outside the graph".to_string()); + } + Ok(Self::new( + spec.graph, + weights, + spec.root, + spec.requirements, + spec.capacity, + )) + } +} + impl MinimumCapacitatedSpanningTree { /// Create a MinimumCapacitatedSpanningTree problem. /// @@ -323,7 +366,7 @@ where } crate::declare_variants! { - default MinimumCapacitatedSpanningTree => "2^num_edges", + default MinimumCapacitatedSpanningTree => "2^num_edges" create MinimumCapacitatedSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 6ebaa7af6..36e986e31 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -4,7 +4,7 @@ //! bounded-size sets (containing designated source and sink vertices) that //! minimizes total cut weight. From Garey & Johnson, A2 ND17. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,13 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-weight cut partitioning vertices into two bounded-size sets", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G = (V, E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z+" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s (must be in V1)" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t (must be in V2)" }, - FieldInfo { name: "size_bound", type_name: "usize", description: "Maximum size B for each partition set" }, - ], + fields: MinimumCutIntoBoundedSetsCreateSpec::FIELDS, } } @@ -75,6 +69,44 @@ pub struct MinimumCutIntoBoundedSets { size_bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCutIntoBoundedSetsCreateSpec { + /// The undirected graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Maximum size for each partition set. + size_bound: usize, +} +impl TryFrom for MinimumCutIntoBoundedSets { + type Error = String; + fn try_from(spec: MinimumCutIntoBoundedSetsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec.edge_weights.unwrap_or_else(|| vec![1; count]); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices || spec.source == spec.sink { + return Err("source and sink must be distinct valid graph vertices".to_string()); + } + Ok(Self::new( + spec.graph, + edge_weights, + spec.source, + spec.sink, + spec.size_bound, + )) + } +} + impl MinimumCutIntoBoundedSets { /// Create a new MinimumCutIntoBoundedSets problem. /// @@ -228,7 +260,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", + default MinimumCutIntoBoundedSets => "2^num_vertices" create MinimumCutIntoBoundedSetsCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index fbedd5e05..a780fdd7b 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -4,7 +4,7 @@ //! such that every vertex is either in the set or adjacent to a vertex in the set. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -23,10 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight dominating set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumDominatingSetCreateSpec::::FIELDS, } } @@ -62,6 +59,30 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDominatingSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> + for MinimumDominatingSet +{ + type Error = String; + fn try_from(spec: MinimumDominatingSetCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MinimumDominatingSet { /// Create a Dominating Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -165,8 +186,8 @@ where } crate::declare_variants! { - default MinimumDominatingSet => "1.4969^num_vertices", - MinimumDominatingSet => "1.4969^num_vertices", + default MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec, + MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet @@ -245,6 +266,14 @@ inventory::submit! { }, is_default: false, aliases: &[], + create_inputs: None, + construct_fn: |data| { + let problem_type = > as Problem>::problem_type(); + crate::registry::validate_direct_create_inputs(problem_type.fields, &data)?; + serde_json::from_value::>>(data) + .map(|problem| Box::new(problem) as Box) + .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string())) + }, factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs index c4a8dbdb0..2c3b62251 100644 --- a/src/models/graph/minimum_dummy_activities_pert.rs +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -7,7 +7,7 @@ //! resulting event network is acyclic and preserves exactly the same //! task-to-task reachability relation as the original DAG. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -22,13 +22,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a PERT event network for a precedence DAG minimizing dummy activities", - fields: &[ - FieldInfo { - name: "graph", - type_name: "DirectedGraph", - description: "The precedence DAG G=(V,A) whose vertices are tasks and arcs encode direct precedence constraints", - }, - ], + fields: MinimumDummyActivitiesPertCreateSpec::FIELDS, } } @@ -46,6 +40,33 @@ pub struct MinimumDummyActivitiesPert { graph: DirectedGraph, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDummyActivitiesPertCreateSpec { + /// Directed precedence arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated tasks. + num_vertices: Option, +} +impl TryFrom for MinimumDummyActivitiesPert { + type Error = String; + fn try_from(spec: MinimumDummyActivitiesPertCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for the provided arcs".into()); + } + Self::try_new(DirectedGraph::new(num_vertices, spec.arcs)) + } +} + impl MinimumDummyActivitiesPert { /// Fallible constructor used by CLI validation and deserialization. pub fn try_new(graph: DirectedGraph) -> Result { @@ -201,7 +222,7 @@ impl Problem for MinimumDummyActivitiesPert { } crate::declare_variants! { - default MinimumDummyActivitiesPert => "2^num_arcs", + default MinimumDummyActivitiesPert => "2^num_arcs" create MinimumDummyActivitiesPertCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index a69fa1aa6..14049d9e1 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -3,7 +3,7 @@ //! The Feedback Arc Set problem asks for a minimum-weight subset of arcs //! whose removal makes a directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight feedback arc set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w: A -> R" }, - ], + fields: MinimumFeedbackArcSetCreateSpec::FIELDS, } } @@ -65,6 +62,28 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackArcSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Arc weights; defaults to one per arc. + weights: Option>, +} +impl TryFrom for MinimumFeedbackArcSet { + type Error = String; + fn try_from(spec: MinimumFeedbackArcSetCreateSpec) -> Result { + let count = spec.graph.num_arcs(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackArcSet { /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -165,7 +184,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - default MinimumFeedbackArcSet => "2^num_vertices", + default MinimumFeedbackArcSet => "2^num_vertices" create MinimumFeedbackArcSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index fe6b277e4..cb1130bf6 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -3,7 +3,7 @@ //! The Feedback Vertex Set problem asks for a minimum weight subset of vertices //! whose removal makes the directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight feedback vertex set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumFeedbackVertexSetCreateSpec::FIELDS, } } @@ -59,6 +56,28 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackVertexSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Vertex weights; defaults to one per vertex. + weights: Option>, +} +impl TryFrom for MinimumFeedbackVertexSet { + type Error = String; + fn try_from(spec: MinimumFeedbackVertexSetCreateSpec) -> Result { + let count = spec.graph.num_vertices(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackVertexSet { /// Create a Feedback Vertex Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -153,7 +172,7 @@ where } crate::declare_variants! { - default MinimumFeedbackVertexSet => "1.9977^num_vertices", + default MinimumFeedbackVertexSet => "1.9977^num_vertices" create MinimumFeedbackVertexSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 010a27bb7..2a5009b4f 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -3,7 +3,7 @@ //! The Minimum Multiway Cut problem asks for a minimum weight set of edges //! whose removal disconnects all terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight set of edges whose removal disconnects all terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices that must be separated" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R (same order as graph.edges())" }, - ], + fields: MinimumMultiwayCutCreateSpec::FIELDS, } } @@ -52,6 +48,49 @@ pub struct MinimumMultiwayCut { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumMultiwayCutCreateSpec { + /// The undirected graph G=(V,E). + graph: SimpleGraph, + /// Terminal vertices that must be separated. + terminals: Vec, + /// Edge weights w: E -> R in graph edge order. + edge_weights: Vec, +} + +impl TryFrom for MinimumMultiwayCut { + type Error = String; + fn try_from(spec: MinimumMultiwayCutCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.terminals, spec.edge_weights)) + } +} + impl MinimumMultiwayCut { /// Create a MinimumMultiwayCut problem. /// @@ -188,7 +227,7 @@ where } crate::declare_variants! { - default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3", + default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3" create MinimumMultiwayCutCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index e631f0d7c..34d652945 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -3,7 +3,7 @@ //! The p-median problem asks for K facility locations (centers) on a graph //! that minimize the total weighted distance from all vertices to their nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -21,12 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find K centers minimizing total weighted distance (p-median problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinimumSumMulticenterCreateSpec::FIELDS, } } @@ -70,6 +65,76 @@ pub struct MinimumSumMulticenter { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSumMulticenterCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, +} + +impl TryFrom for MinimumSumMulticenter { + type Error = String; + + fn try_from(spec: MinimumSumMulticenterCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![1; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinimumSumMulticenter { /// Create a MinimumSumMulticenter problem. /// @@ -248,7 +313,7 @@ where } crate::declare_variants! { - default MinimumSumMulticenter => "2^num_vertices", + default MinimumSumMulticenter => "2^num_vertices" create MinimumSumMulticenterCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index f33b93134..e219c2d72 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -4,7 +4,7 @@ //! such that every edge has at least one endpoint in the subset. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight vertex cover in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumVertexCoverCreateSpec::::FIELDS, } } @@ -62,6 +59,33 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumVertexCoverCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Option>, +} + +impl TryFrom> + for MinimumVertexCover +{ + type Error = String; + fn try_from(spec: MinimumVertexCoverCreateSpec) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![W::default(); spec.graph.num_vertices()]); + if weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumVertexCover { /// Create a Vertex Covering problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -152,8 +176,8 @@ pub(crate) fn is_vertex_cover_config(graph: &G, config: &[usize]) -> b } crate::declare_variants! { - default MinimumVertexCover => "1.1996^num_vertices", - MinimumVertexCover => "1.1996^num_vertices", + default MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec, + MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index af333f700..852372323 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -4,7 +4,7 @@ //! minimum-cost closed walk that traverses every directed arc in its prescribed //! direction and every undirected edge in at least one direction. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{DirectedGraph, MixedGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -24,11 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-cost closed walk covering all arcs and edges in a mixed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Lengths for the directed arcs in A" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Lengths for the undirected edges in E" }, - ], + fields: MixedChinesePostmanI32CreateSpec::FIELDS, } } @@ -45,6 +41,81 @@ pub struct MixedChinesePostman> { edge_weights: Vec, } +macro_rules! mixed_chinese_postman_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Directed-arc lengths; defaults to one per arc. + #[create(codec = "comma-separated")] + arc_weights: Option>, + /// Undirected-edge lengths; defaults to one per edge. + #[create(codec = "comma-separated")] + edge_weights: Option>, + } + + impl TryFrom<$name> for MixedChinesePostman<$weight> { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + for (index, &(u, v)) in spec.arcs.iter().enumerate() { + if u >= num_vertices || v >= num_vertices { + return Err(format!( + "arc {index} endpoint is out of range for {num_vertices} vertices" + )); + } + } + let arc_weights = spec + .arc_weights + .unwrap_or_else(|| vec![$one; spec.arcs.len()]); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![$one; spec.graph.len()]); + MixedChinesePostman::try_new( + MixedGraph::new(num_vertices, spec.arcs, spec.graph), + arc_weights, + edge_weights, + ) + } + } + }; +} + +mixed_chinese_postman_create_spec!(MixedChinesePostmanI32CreateSpec, i32, 1_i32); +mixed_chinese_postman_create_spec!(MixedChinesePostmanOneCreateSpec, One, One); + impl> MixedChinesePostman { /// Create a new mixed Chinese postman instance. /// @@ -53,42 +124,44 @@ impl> MixedChinesePostman { /// Panics if the weight-vector lengths do not match the graph shape or if /// any weight is negative. pub fn new(graph: MixedGraph, arc_weights: Vec, edge_weights: Vec) -> Self { - assert_eq!( - arc_weights.len(), - graph.num_arcs(), - "arc_weights length must match num_arcs" - ); - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); + Self::try_new(graph, arc_weights, edge_weights) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + ) -> Result { + if arc_weights.len() != graph.num_arcs() { + return Err("arc_weights length must match num_arcs".to_string()); + } + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".to_string()); + } for (index, weight) in arc_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "arc weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("arc weight at index {index} must be nonnegative")); + } } for (index, weight) in edge_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "edge weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("edge weight at index {index} must be nonnegative")); + } } - Self { + Ok(Self { graph, arc_weights, edge_weights, - } + }) } /// Return the mixed graph. @@ -238,8 +311,8 @@ where } crate::declare_variants! { - default MixedChinesePostman => "2^num_edges * num_vertices^3", - MixedChinesePostman => "2^num_edges * num_vertices^3", + default MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanI32CreateSpec, + MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index c0e90cdba..f32796695 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -4,7 +4,7 @@ //! threshold, determine whether there exists a high-weight branching that //! picks at most one arc from each partition group. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -22,12 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a branching with partition constraints and weight at least K", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w(a) for each arc a in A" }, - FieldInfo { name: "partition", type_name: "Vec>", description: "Partition of arc indices; each arc index must appear in exactly one group" }, - FieldInfo { name: "threshold", type_name: "W::Sum", description: "Weight threshold K" }, - ], + fields: MultipleChoiceBranchingCreateSpec::FIELDS, } } @@ -48,6 +43,56 @@ pub struct MultipleChoiceBranching { threshold: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleChoiceBranchingCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc weights w(a) for each arc a in A. + weights: Vec, + /// Partition of arc indices; each arc must appear exactly once. + partition: Vec>, + /// Weight threshold K. + threshold: i64, +} + +impl TryFrom for MultipleChoiceBranching { + type Error = String; + fn try_from(spec: MultipleChoiceBranchingCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for arc endpoints".to_string()); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let num_arcs = graph.num_arcs(); + if spec.weights.len() != num_arcs { + return Err(format!( + "weights has {} entries, expected {num_arcs}", + spec.weights.len() + )); + } + if let Some(message) = partition_validation_error(&spec.partition, num_arcs) { + return Err(message); + } + Ok(Self::new( + graph, + spec.weights, + spec.partition, + spec.threshold, + )) + } +} + #[derive(Debug, Deserialize)] struct MultipleChoiceBranchingUnchecked { graph: DirectedGraph, @@ -294,7 +339,7 @@ fn is_valid_multiple_choice_branching( } crate::declare_variants! { - default MultipleChoiceBranching => "2^num_arcs", + default MultipleChoiceBranching => "2^num_arcs" create MultipleChoiceBranchingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index c54269d30..a3b6d00db 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -3,7 +3,7 @@ //! The Multiple Copy File Allocation problem asks for a placement of file copies //! on graph vertices that minimizes the combined storage and access cost. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Place file copies on graph vertices to minimize total storage plus access cost", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The network graph G=(V,E)" }, - FieldInfo { name: "usage", type_name: "Vec", description: "Usage frequencies u(v) for each vertex" }, - FieldInfo { name: "storage", type_name: "Vec", description: "Storage costs s(v) for placing a copy at each vertex" }, - ], + fields: MultipleCopyFileAllocationCreateSpec::FIELDS, } } @@ -49,6 +45,58 @@ pub struct MultipleCopyFileAllocation { storage: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleCopyFileAllocationCreateSpec { + /// Network graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Usage frequency per vertex. + #[create(codec = "comma-separated")] + usage: Vec, + /// Storage cost per vertex. + #[create(codec = "comma-separated")] + storage: Vec, +} + +impl TryFrom for MultipleCopyFileAllocation { + type Error = String; + fn try_from(spec: MultipleCopyFileAllocationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.usage.len() != count { + return Err("usage length must match num_vertices".into()); + } + if spec.storage.len() != count { + return Err("storage length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + usage: spec.usage, + storage: spec.storage, + }) + } +} + impl MultipleCopyFileAllocation { /// Create a new Multiple Copy File Allocation instance. pub fn new(graph: SimpleGraph, usage: Vec, storage: Vec) -> Self { @@ -201,7 +249,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "2^num_vertices", + default MultipleCopyFileAllocation => "2^num_vertices" create MultipleCopyFileAllocationCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/partial_feedback_edge_set.rs b/src/models/graph/partial_feedback_edge_set.rs index 5806cd534..ef988d001 100644 --- a/src/models/graph/partial_feedback_edge_set.rs +++ b/src/models/graph/partial_feedback_edge_set.rs @@ -3,7 +3,7 @@ //! The Partial Feedback Edge Set problem asks whether removing at most `K` //! edges can hit every cycle of length at most `L`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,11 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Remove at most K edges so that every cycle of length at most L is hit", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number K of edges that may be removed" }, - FieldInfo { name: "max_cycle_length", type_name: "usize", description: "Cycle length bound L; every cycle with length at most L must be hit" }, - ], + fields: PartialFeedbackEdgeSetCreateSpec::FIELDS, } } @@ -46,6 +42,23 @@ pub struct PartialFeedbackEdgeSet { max_cycle_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartialFeedbackEdgeSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Maximum number K of edges that may be removed. + budget: usize, + /// Cycle length bound L. + max_cycle_length: usize, +} + +impl TryFrom for PartialFeedbackEdgeSet { + type Error = String; + fn try_from(spec: PartialFeedbackEdgeSetCreateSpec) -> Result { + Ok(Self::new(spec.graph, spec.budget, spec.max_cycle_length)) + } +} + impl PartialFeedbackEdgeSet { /// Create a new Partial Feedback Edge Set instance. pub fn new(graph: G, budget: usize, max_cycle_length: usize) -> Self { @@ -242,7 +255,7 @@ fn normalize_edge(u: usize, v: usize) -> (usize, usize) { } crate::declare_variants! { - default PartialFeedbackEdgeSet => "2^num_edges", + default PartialFeedbackEdgeSet => "2^num_edges" create PartialFeedbackEdgeSetCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index 373598e22..8ad111ff2 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -6,7 +6,7 @@ //! capacities are respected and the total delivered flow reaches the required //! threshold. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,14 +20,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a prescribed collection of directed s-t paths", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "paths", type_name: "Vec>", description: "Prescribed directed s-t paths as arc-index sequences" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required total flow R" }, - ], + fields: PathConstrainedNetworkFlowCreateSpec::FIELDS, } } @@ -49,6 +42,64 @@ pub struct PathConstrainedNetworkFlow { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PathConstrainedNetworkFlowCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc capacities; defaults to one per arc. + #[create(codec = "comma-separated")] + capacities: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Prescribed paths as arc-index sequences. + #[create(codec = "semicolon-separated")] + paths: Vec>, + /// Required total flow. + requirement: u64, +} + +impl TryFrom for PathConstrainedNetworkFlow { + type Error = String; + + fn try_from(spec: PathConstrainedNetworkFlowCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.paths.is_empty() { + return Err("paths must be non-empty".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let graph = DirectedGraph::new(num_vertices, spec.arcs); + Self::try_new( + graph, + capacities, + spec.source, + spec.sink, + spec.paths, + spec.requirement, + ) + } +} + impl PathConstrainedNetworkFlow { /// Create a new Path-Constrained Network Flow instance. /// @@ -66,38 +117,59 @@ impl PathConstrainedNetworkFlow { paths: Vec>, requirement: u64, ) -> Self { + Self::try_new(graph, capacities, source, sink, paths, requirement) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: u64, + ) -> Result { let num_vertices = graph.num_vertices(); - assert_eq!( - capacities.len(), - graph.num_arcs(), - "capacities length must match graph num_arcs" - ); - assert!( - source < num_vertices, - "source ({source}) >= num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) >= num_vertices ({num_vertices})" - ); - assert_ne!(source, sink, "source and sink must be distinct"); - - for path in &paths { - Self::assert_valid_path(&graph, path, source, sink); + if capacities.len() != graph.num_arcs() { + return Err("capacities length must match graph num_arcs".to_string()); + } + if source >= num_vertices { + return Err(format!( + "source ({source}) >= num_vertices ({num_vertices})" + )); + } + if sink >= num_vertices { + return Err(format!("sink ({sink}) >= num_vertices ({num_vertices})")); + } + if source == sink { + return Err("source and sink must be distinct".to_string()); + } + + for (index, path) in paths.iter().enumerate() { + Self::validate_path(&graph, path, source, sink) + .map_err(|message| format!("path {index}: {message}"))?; } - Self { + Ok(Self { graph, capacities, source, sink, paths, requirement, - } + }) } - fn assert_valid_path(graph: &DirectedGraph, path: &[usize], source: usize, sink: usize) { - assert!(!path.is_empty(), "prescribed paths must be non-empty"); + fn validate_path( + graph: &DirectedGraph, + path: &[usize], + source: usize, + sink: usize, + ) -> Result<(), String> { + if path.is_empty() { + return Err("prescribed paths must be non-empty".to_string()); + } let arcs = graph.arcs(); let mut visited_vertices = HashSet::from([source]); @@ -106,22 +178,21 @@ impl PathConstrainedNetworkFlow { for &arc_idx in path { let &(tail, head) = arcs .get(arc_idx) - .unwrap_or_else(|| panic!("path arc index {arc_idx} out of bounds")); - assert_eq!( - tail, current, - "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" - ); - assert!( - visited_vertices.insert(head), - "prescribed path repeats vertex {head}, so it is not a simple path" - ); + .ok_or_else(|| format!("arc index {arc_idx} out of bounds"))?; + if tail != current { + return Err(format!( + "not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" + )); + } + if !visited_vertices.insert(head) { + return Err(format!("repeats vertex {head}, so it is not a simple path")); + } current = head; } - - assert_eq!( - current, sink, - "prescribed path must end at sink {sink}, ended at {current}" - ); + if current != sink { + return Err(format!("must end at sink {sink}, ended at {current}")); + } + Ok(()) } fn path_bottleneck(&self, path: &[usize]) -> u64 { @@ -235,7 +306,7 @@ impl Problem for PathConstrainedNetworkFlow { } crate::declare_variants! { - default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths", + default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths" create PathConstrainedNetworkFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index d44281dd0..e970a4aa8 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -24,7 +24,7 @@ //! - Earlier conference version, RECOMB 2012, LNBI 7262, pp. 287--301. //! -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -44,13 +44,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a forest minimizing omitted-prize plus edge-cost plus omega times the number of tree components", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying network G=(V,E)" }, - FieldInfo { name: "vertex_prizes", type_name: "Vec", description: "Nonnegative vertex prizes p: V -> R_{>=0}" }, - FieldInfo { name: "edge_costs", type_name: "Vec", description: "Nonnegative edge costs c: E -> R_{>=0} in graph.edges() order" }, - FieldInfo { name: "beta", type_name: "W", description: "Tradeoff coefficient beta >= 0 on the omitted-prize term" }, - FieldInfo { name: "omega", type_name: "W", description: "Per-component penalty omega >= 0 on the number of tree components" }, - ], + fields: PrizeCollectingSteinerForestI32CreateSpec::FIELDS, } } @@ -110,6 +104,89 @@ pub struct PrizeCollectingSteinerForest { omega: W, } +macro_rules! prize_collecting_steiner_forest_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + vertex_prizes: Option>, + #[create(codec = "comma-separated")] + edge_costs: Option>, + beta: $weight, + omega: $weight, + } + + impl TryFrom<$name> for PrizeCollectingSteinerForest { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_prizes = spec + .vertex_prizes + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_prizes.len() != graph.num_vertices() { + return Err(format!( + "vertex_prizes has length {}, expected {}", + vertex_prizes.len(), + graph.num_vertices() + )); + } + let edge_costs = spec + .edge_costs + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_costs.len() != graph.num_edges() { + return Err(format!( + "edge_costs has length {}, expected {}", + edge_costs.len(), + graph.num_edges() + )); + } + Ok(Self::new( + graph, + vertex_prizes, + edge_costs, + spec.beta, + spec.omega, + )) + } + } + }; +} + +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestI32CreateSpec, i32, 1); +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestF64CreateSpec, f64, 1.0); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl PrizeCollectingSteinerForest { /// Create a new Prize-Collecting Steiner Forest instance. /// @@ -306,8 +383,8 @@ fn forest_components(graph: &G, config: &[usize]) -> Option { } crate::declare_variants! { - default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", - PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", + default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestI32CreateSpec, + PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 164eccdc0..bb2f5c5b2 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -3,7 +3,7 @@ //! The Rural Postman problem asks for a minimum-cost circuit in a graph //! that includes each edge in a required subset E'. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-cost circuit covering all required edges (Rural Postman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge lengths l(e) for each e in E" }, - FieldInfo { name: "required_edges", type_name: "Vec", description: "Edge indices of the required subset E' ⊆ E" }, - ], + fields: RuralPostmanCreateSpec::FIELDS, } } @@ -65,6 +61,71 @@ pub struct RuralPostman { required_edges: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuralPostmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + #[create(codec = "comma-separated")] + required_edges: Vec, +} + +impl TryFrom for RuralPostman { + type Error = String; + + fn try_from(spec: RuralPostmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if let Some(&edge) = spec + .required_edges + .iter() + .find(|&&edge| edge >= graph.num_edges()) + { + return Err(format!("required edge index {edge} is out of bounds")); + } + Ok(Self::new(graph, edge_lengths, spec.required_edges)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl RuralPostman { /// Create a new RuralPostman problem. /// @@ -266,7 +327,7 @@ where } crate::declare_variants! { - default RuralPostman => "2^num_vertices * num_vertices^2", + default RuralPostman => "2^num_vertices * num_vertices^2" create RuralPostmanCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index bfc1272b8..4494edf6e 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -4,7 +4,7 @@ //! source vertex to a target vertex that minimizes total length while keeping //! the total weight within a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -23,14 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple s-t path minimizing total length subject to a weight budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound W on total path weight" }, - ], + fields: ShortestWeightConstrainedPathCreateSpec::FIELDS, } } @@ -74,6 +67,73 @@ pub struct ShortestWeightConstrainedPath { weight_bound: N::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestWeightConstrainedPathCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Positive edge lengths in graph edge order. + edge_lengths: Vec, + /// Positive edge weights in graph edge order. + edge_weights: Vec, + /// Source vertex s. + source_vertex: usize, + /// Target vertex t. + target_vertex: usize, + /// Positive upper bound on total path weight. + weight_bound: i64, +} + +impl TryFrom + for ShortestWeightConstrainedPath +{ + type Error = String; + fn try_from(spec: ShortestWeightConstrainedPathCreateSpec) -> Result { + let edge_count = spec.graph.num_edges(); + if spec.edge_lengths.len() != edge_count { + return Err(format!( + "edge_lengths has {} entries, expected {edge_count}", + spec.edge_lengths.len() + )); + } + if spec.edge_weights.len() != edge_count { + return Err(format!( + "edge_weights has {} entries, expected {edge_count}", + spec.edge_weights.len() + )); + } + if spec.edge_lengths.iter().any(|&value| value <= 0) { + return Err("edge_lengths must be positive".to_string()); + } + if spec.edge_weights.iter().any(|&value| value <= 0) { + return Err("edge_weights must be positive".to_string()); + } + let vertex_count = spec.graph.num_vertices(); + if spec.source_vertex >= vertex_count { + return Err(format!( + "source_vertex {} is outside graph with {vertex_count} vertices", + spec.source_vertex + )); + } + if spec.target_vertex >= vertex_count { + return Err(format!( + "target_vertex {} is outside graph with {vertex_count} vertices", + spec.target_vertex + )); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + Ok(Self::new( + spec.graph, + spec.edge_lengths, + spec.edge_weights, + spec.source_vertex, + spec.target_vertex, + spec.weight_bound, + )) + } +} + impl ShortestWeightConstrainedPath { fn assert_positive_edge_values(values: &[N], label: &str) { let zero = N::Sum::zero(); @@ -349,7 +409,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_edges", + default ShortestWeightConstrainedPath => "2^num_edges" create ShortestWeightConstrainedPathCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index 0e86144a5..a3f51e55b 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -2,7 +2,7 @@ //! //! The Spin Glass problem minimizes the Ising Hamiltonian energy. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -19,11 +19,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Minimize Ising Hamiltonian on a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The interaction graph" }, - FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings J_ij" }, - FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields h_i" }, - ], + fields: SpinGlassI32CreateSpec::FIELDS, } } @@ -73,6 +69,72 @@ pub struct SpinGlass { fields: Vec, } +macro_rules! spin_glass_create_spec { + ($name:ident, $weight:ty, $one:expr, $zero:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Undirected interaction graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated spins. + num_vertices: Option, + /// Pairwise couplings; defaults to one per edge. + #[create(codec = "comma-separated")] + couplings: Option>, + /// On-site fields; defaults to zero per vertex. + #[create(codec = "comma-separated")] + fields: Option>, + } + + impl TryFrom<$name> for SpinGlass { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + let couplings = spec + .couplings + .unwrap_or_else(|| vec![$one; spec.graph.len()]); + if couplings.len() != spec.graph.len() { + return Err("couplings length must match graph edge count".to_string()); + } + let fields = spec.fields.unwrap_or_else(|| vec![$zero; num_vertices]); + if fields.len() != num_vertices { + return Err("fields length must match num_vertices".to_string()); + } + Ok(SpinGlass { + graph: SimpleGraph::new(num_vertices, spec.graph), + couplings, + fields, + }) + } + } + }; +} + +spin_glass_create_spec!(SpinGlassI32CreateSpec, i32, 1_i32, 0_i32); +spin_glass_create_spec!(SpinGlassF64CreateSpec, f64, 1.0_f64, 0.0_f64); + impl SpinGlass { /// Create a new Spin Glass problem. /// @@ -237,8 +299,8 @@ where } crate::declare_variants! { - default SpinGlass => "2^num_spins", - SpinGlass => "2^num_spins", + default SpinGlass => "2^num_spins" create SpinGlassI32CreateSpec, + SpinGlass => "2^num_spins" create SpinGlassF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index b58f8c331..83436d678 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -9,7 +9,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; use crate::{ - registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}, + registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}, topology::{Graph, SimpleGraph}, traits::Problem, types::{Min, One, WeightElement}, @@ -26,11 +26,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight tree connecting terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices T that must be connected" }, - ], + fields: SteinerTreeCreateSpec::::FIELDS, } } @@ -64,6 +60,49 @@ pub struct SteinerTree { terminals: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Edge weights w: E -> R. + edge_weights: Vec, + /// Terminal vertices T that must be connected. + terminals: Vec, +} + +impl TryFrom> for SteinerTree { + type Error = String; + fn try_from(spec: SteinerTreeCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.edge_weights, spec.terminals)) + } +} + impl SteinerTree { /// Create a SteinerTree problem from a graph, edge weights, and terminals. pub fn new(graph: G, edge_weights: Vec, terminals: Vec) -> Self { @@ -248,8 +287,8 @@ where } crate::declare_variants! { - default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", - SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", + default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec, + SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs index 9a191078c..4fbc64a2c 100644 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -3,7 +3,7 @@ //! The Steiner Tree problem asks for a minimum-weight subtree of a graph //! that connects all terminal vertices. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -21,11 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight subtree connecting all terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Required terminal vertices R ⊆ V" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: SteinerTreeInGraphsCreateSpec::::FIELDS, } } @@ -77,6 +73,42 @@ pub struct SteinerTreeInGraphs { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeInGraphsCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Required terminal vertices. + terminals: Vec, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, +} +impl TryFrom> for SteinerTreeInGraphs +where + W: Clone + Default + From, +{ + type Error = String; + fn try_from(spec: SteinerTreeInGraphsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!("terminal {terminal} is outside the graph")); + } + Ok(Self::new(spec.graph, spec.terminals, edge_weights)) + } +} + impl SteinerTreeInGraphs { /// Create a SteinerTreeInGraphs problem from a graph, terminals, and edge weights. /// @@ -274,8 +306,8 @@ pub(crate) fn is_steiner_tree(graph: &G, terminals: &[usize], selected } crate::declare_variants! { - default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", - SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", + default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, + SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index 017fabf7c..fdd71417e 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Traveling Salesman problem asks for a minimum-weight cycle //! that visits every vertex exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight Hamiltonian cycle in a graph (Traveling Salesman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: TravelingSalesmanCreateSpec::FIELDS, } } @@ -57,6 +54,62 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for TravelingSalesman { + type Error = String; + + fn try_from(spec: TravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl TravelingSalesman { /// Create a TravelingSalesman problem from a graph with given edge weights. pub fn new(graph: G, edge_weights: Vec) -> Self { @@ -260,7 +313,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", + default TravelingSalesman => "2^num_vertices" create TravelingSalesmanCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index be86186dd..38822478d 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -13,7 +13,7 @@ //! lower bounds, so the registered exact complexity matches brute-force //! enumeration over the `2^|E|` edge orientations. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -27,14 +27,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether an undirected lower-bounded flow of value at least R exists", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Upper capacities c(e) in graph edge order" }, - FieldInfo { name: "lower_bounds", type_name: "Vec", description: "Lower bounds l(e) in graph edge order" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at sink t" }, - ], + fields: UndirectedFlowLowerBoundsCreateSpec::FIELDS, } } @@ -55,6 +48,67 @@ pub struct UndirectedFlowLowerBounds { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedFlowLowerBoundsCreateSpec { + /// Undirected graph. + graph: SimpleGraph, + /// Upper capacities in graph edge order. + capacities: Vec, + /// Lower bounds in graph edge order. + lower_bounds: Vec, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Required net inflow at the sink. + requirement: u64, +} +impl TryFrom for UndirectedFlowLowerBounds { + type Error = String; + fn try_from(spec: UndirectedFlowLowerBoundsCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + if spec.capacities.len() != edges { + return Err(format!( + "capacities has {} entries, expected {edges}", + spec.capacities.len() + )); + } + if spec.lower_bounds.len() != edges { + return Err(format!( + "lower_bounds has {} entries, expected {edges}", + spec.lower_bounds.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.requirement == 0 { + return Err("requirement must be at least 1".to_string()); + } + if let Some((index, _)) = spec + .lower_bounds + .iter() + .zip(&spec.capacities) + .enumerate() + .find(|(_, (&lower, &upper))| lower > upper) + { + return Err(format!("lower bound at edge {index} exceeds its capacity")); + } + Ok(Self::new( + spec.graph, + spec.capacities, + spec.lower_bounds, + spec.source, + spec.sink, + spec.requirement, + )) + } +} + impl UndirectedFlowLowerBounds { pub fn new( graph: SimpleGraph, @@ -232,7 +286,7 @@ impl Problem for UndirectedFlowLowerBounds { } crate::declare_variants! { - default UndirectedFlowLowerBounds => "2^num_edges", + default UndirectedFlowLowerBounds => "2^num_edges" create UndirectedFlowLowerBoundsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index 6159336b8..9d8666821 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -3,7 +3,7 @@ //! The problem asks whether two integral commodities can be routed through an //! undirected capacitated graph while sharing edge capacities. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,16 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether two integral commodities can satisfy sink demands in an undirected capacitated graph", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Edge capacities c(e) in graph edge order" }, - FieldInfo { name: "source_1", type_name: "usize", description: "Source vertex s_1 for commodity 1" }, - FieldInfo { name: "sink_1", type_name: "usize", description: "Sink vertex t_1 for commodity 1" }, - FieldInfo { name: "source_2", type_name: "usize", description: "Source vertex s_2 for commodity 2" }, - FieldInfo { name: "sink_2", type_name: "usize", description: "Sink vertex t_2 for commodity 2" }, - FieldInfo { name: "requirement_1", type_name: "u64", description: "Required net inflow R_1 at sink t_1" }, - FieldInfo { name: "requirement_2", type_name: "u64", description: "Required net inflow R_2 at sink t_2" }, - ], + fields: UndirectedTwoCommodityIntegralFlowCreateSpec::FIELDS, } } @@ -56,6 +47,82 @@ pub struct UndirectedTwoCommodityIntegralFlow { requirement_2: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedTwoCommodityIntegralFlowCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Edge capacities. + #[create(codec = "comma-separated")] + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: u64, + requirement_2: u64, +} + +impl TryFrom for UndirectedTwoCommodityIntegralFlow { + type Error = String; + fn try_from(spec: UndirectedTwoCommodityIntegralFlowCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.capacities.len() != spec.graph.len() { + return Err("capacities length must match graph edge count".into()); + } + for &capacity in &spec.capacities { + if usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large for this platform".into()); + } + } + for (label, vertex) in [ + ("source_1", spec.source_1), + ("sink_1", spec.sink_1), + ("source_2", spec.source_2), + ("sink_2", spec.sink_2), + ] { + if vertex >= count { + return Err(format!("{label} must be less than num_vertices")); + } + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + capacities: spec.capacities, + source_1: spec.source_1, + sink_1: spec.sink_1, + source_2: spec.source_2, + sink_2: spec.sink_2, + requirement_1: spec.requirement_1, + requirement_2: spec.requirement_2, + }) + } +} + impl UndirectedTwoCommodityIntegralFlow { #[allow(clippy::too_many_arguments)] pub fn new( @@ -299,7 +366,7 @@ impl Problem for UndirectedTwoCommodityIntegralFlow { } crate::declare_variants! { - default UndirectedTwoCommodityIntegralFlow => "5^num_edges", + default UndirectedTwoCommodityIntegralFlow => "5^num_edges" create UndirectedTwoCommodityIntegralFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/boyce_codd_normal_form_violation.rs b/src/models/misc/boyce_codd_normal_form_violation.rs index 3e465c5bf..7d6cc1332 100644 --- a/src/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/models/misc/boyce_codd_normal_form_violation.rs @@ -5,7 +5,7 @@ //! `X ⊆ A'` such that the closure of `X` under the functional dependencies contains //! some but not all attributes of `A' \ X` — i.e., a witness to a BCNF violation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Test whether a subset of attributes violates Boyce-Codd normal form", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Total number of attributes in A" }, - FieldInfo { name: "functional_deps", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs_attributes, rhs_attributes)" }, - FieldInfo { name: "target_subset", type_name: "Vec", description: "Subset A' of attributes to test for BCNF violation" }, - ], + fields: BoyceCoddNormalFormViolationCreateSpec::FIELDS, } } @@ -68,6 +64,51 @@ pub struct BoyceCoddNormalFormViolation { target_subset: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoyceCoddNormalFormViolationCreateSpec { + /// Total number of attributes in A. + n: usize, + /// Functional dependencies (lhs attributes, rhs attributes). + #[create(codec = "functional-dependency-list")] + subsets: Vec<(Vec, Vec)>, + /// Subset A' of attributes to test for BCNF violation. + target: Vec, +} + +impl TryFrom for BoyceCoddNormalFormViolation { + type Error = String; + + fn try_from(spec: BoyceCoddNormalFormViolationCreateSpec) -> Result { + if spec.target.is_empty() { + return Err("target must be non-empty".to_string()); + } + for (dependency_index, (lhs, rhs)) in spec.subsets.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "subsets[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.n) + { + return Err(format!( + "subsets[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + } + if let Some(&attribute) = spec.target.iter().find(|&&attribute| attribute >= spec.n) { + return Err(format!( + "target contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + Ok(Self::new(spec.n, spec.subsets, spec.target)) + } +} + impl BoyceCoddNormalFormViolation { /// Create a new Boyce-Codd Normal Form Violation instance. /// @@ -216,7 +257,7 @@ impl Problem for BoyceCoddNormalFormViolation { } crate::declare_variants! { - default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps", + default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps" create BoyceCoddNormalFormViolationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index cd4426daf..ecb7e27e5 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -3,7 +3,7 @@ //! Capacity Assignment asks for the minimum-cost assignment of capacity levels //! to communication links, subject to a delay budget constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,12 +15,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize total cost of capacity assignment subject to a delay budget", - fields: &[ - FieldInfo { name: "capacities", type_name: "Vec", description: "Ordered capacity levels M" }, - FieldInfo { name: "cost", type_name: "Vec>", description: "Cost matrix g(c, m) for each link and capacity" }, - FieldInfo { name: "delay", type_name: "Vec>", description: "Delay matrix d(c, m) for each link and capacity" }, - FieldInfo { name: "delay_budget", type_name: "u64", description: "Budget J on total delay penalty" }, - ], + fields: CapacityAssignmentCreateSpec::FIELDS, } } @@ -38,6 +33,57 @@ pub struct CapacityAssignment { delay_budget: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct CapacityAssignmentCreateSpec { + #[create(codec = "comma-separated")] + capacities: Vec, + #[create(codec = "semicolon-separated")] + cost: Vec>, + #[create(codec = "semicolon-separated")] + delay: Vec>, + delay_budget: u64, +} + +impl TryFrom for CapacityAssignment { + type Error = String; + fn try_from(spec: CapacityAssignmentCreateSpec) -> Result { + if spec.capacities.is_empty() { + return Err("capacities must be non-empty".into()); + } + if spec.capacities.contains(&0) { + return Err("capacities must be positive".into()); + } + if !spec.capacities.windows(2).all(|w| w[0] < w[1]) { + return Err("capacities must be strictly increasing".into()); + } + if spec.cost.len() != spec.delay.len() { + return Err("cost and delay must have the same number of links".into()); + } + for (i, row) in spec.cost.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("cost row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] <= w[1]) { + return Err(format!("cost row {i} must be non-decreasing")); + } + } + for (i, row) in spec.delay.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("delay row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] >= w[1]) { + return Err(format!("delay row {i} must be non-increasing")); + } + } + Ok(Self { + capacities: spec.capacities, + cost: spec.cost, + delay: spec.delay, + delay_budget: spec.delay_budget, + }) + } +} + impl CapacityAssignment { /// Create a new Capacity Assignment instance. pub fn new( @@ -169,7 +215,7 @@ impl Problem for CapacityAssignment { } crate::declare_variants! { - default CapacityAssignment => "num_capacities ^ num_links", + default CapacityAssignment => "num_capacities ^ num_links" create CapacityAssignmentCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/conjunctive_boolean_query.rs b/src/models/misc/conjunctive_boolean_query.rs index e9e4b8737..fad6fdd9e 100644 --- a/src/models/misc/conjunctive_boolean_query.rs +++ b/src/models/misc/conjunctive_boolean_query.rs @@ -10,7 +10,7 @@ //! the domain. The query is satisfiable iff there exists an assignment to the //! variables such that every conjunct's resolved tuple belongs to its relation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -22,12 +22,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Evaluate a conjunctive Boolean query over a relational database", - fields: &[ - FieldInfo { name: "domain_size", type_name: "usize", description: "Size of the finite domain D" }, - FieldInfo { name: "relations", type_name: "Vec", description: "Collection of relations R" }, - FieldInfo { name: "num_variables", type_name: "usize", description: "Number of existentially quantified variables" }, - FieldInfo { name: "conjuncts", type_name: "Vec<(usize, Vec)>", description: "Query conjuncts: (relation_index, arguments)" }, - ], + fields: ConjunctiveBooleanQueryCreateSpec::FIELDS, } } @@ -87,6 +82,89 @@ pub struct ConjunctiveBooleanQuery { conjuncts: Vec<(usize, Vec)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConjunctiveBooleanQueryCreateSpec { + /// Size of the finite domain. + domain_size: usize, + /// Relations evaluated by the query. + #[create(codec = "json")] + relations: Vec, + /// Query atoms; the number of variables is inferred from their arguments. + #[create(codec = "json")] + conjuncts: Vec<(usize, Vec)>, +} + +impl TryFrom for ConjunctiveBooleanQuery { + type Error = String; + + fn try_from(spec: ConjunctiveBooleanQueryCreateSpec) -> Result { + let mut num_variables = 0_usize; + for (_, args) in &spec.conjuncts { + for arg in args { + if let QueryArg::Variable(variable) = arg { + let count = variable + .checked_add(1) + .ok_or_else(|| "number of query variables overflows usize".to_string())?; + num_variables = num_variables.max(count); + } + } + } + + for (relation_index, relation) in spec.relations.iter().enumerate() { + for (tuple_index, tuple) in relation.tuples.iter().enumerate() { + if tuple.len() != relation.arity { + return Err(format!( + "relation {relation_index} tuple {tuple_index} has length {}, expected arity {}", + tuple.len(), + relation.arity + )); + } + for (entry_index, &value) in tuple.iter().enumerate() { + if value >= spec.domain_size { + return Err(format!( + "relation {relation_index} tuple {tuple_index} entry {entry_index} is {value}, must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + for (conjunct_index, (relation_index, args)) in spec.conjuncts.iter().enumerate() { + let relation = spec.relations.get(*relation_index).ok_or_else(|| { + format!( + "conjunct {conjunct_index} relation index {relation_index} is out of range for {} relations", + spec.relations.len() + ) + })?; + if args.len() != relation.arity { + return Err(format!( + "conjunct {conjunct_index} has {} arguments, expected arity {}", + args.len(), + relation.arity + )); + } + for (argument_index, arg) in args.iter().enumerate() { + if let QueryArg::Constant(value) = arg { + if *value >= spec.domain_size { + return Err(format!( + "conjunct {conjunct_index} argument {argument_index} constant {value} must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + Ok(Self { + domain_size: spec.domain_size, + relations: spec.relations, + num_variables, + conjuncts: spec.conjuncts, + }) + } +} + impl ConjunctiveBooleanQuery { /// Create a new ConjunctiveBooleanQuery instance. /// @@ -224,7 +302,7 @@ impl Problem for ConjunctiveBooleanQuery { } crate::declare_variants! { - default ConjunctiveBooleanQuery => "domain_size ^ num_variables", + default ConjunctiveBooleanQuery => "domain_size ^ num_variables" create ConjunctiveBooleanQueryCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 04ade7a9e..250751da7 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -6,7 +6,7 @@ //! assignment of attribute values to all objects that matches every published //! frequency table and every known value. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -90,12 +90,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether pairwise frequency tables and known values admit a consistent complete database assignment", - fields: &[ - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects in the database" }, - FieldInfo { name: "attribute_domains", type_name: "Vec", description: "Domain size for each attribute" }, - FieldInfo { name: "frequency_tables", type_name: "Vec", description: "Published pairwise frequency tables" }, - FieldInfo { name: "known_values", type_name: "Vec", description: "Known object-attribute-value triples" }, - ], + fields: ConsistencyOfDatabaseFrequencyTablesCreateSpec::FIELDS, } } @@ -108,6 +103,110 @@ pub struct ConsistencyOfDatabaseFrequencyTables { known_values: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConsistencyOfDatabaseFrequencyTablesCreateSpec { + /// Number of database objects. + num_objects: usize, + /// Domain size for each attribute. + #[create(codec = "comma-separated")] + attribute_domains: Vec, + /// Pairwise frequency tables as JSON objects. + #[create(codec = "json")] + frequency_tables: Vec, + /// Known object-attribute values as JSON objects; defaults to empty. + #[create(codec = "json")] + known_values: Option>, +} + +impl TryFrom + for ConsistencyOfDatabaseFrequencyTables +{ + type Error = String; + fn try_from(spec: ConsistencyOfDatabaseFrequencyTablesCreateSpec) -> Result { + let known_values = spec.known_values.unwrap_or_default(); + validate_cdft_create( + spec.num_objects, + &spec.attribute_domains, + &spec.frequency_tables, + &known_values, + )?; + Ok(Self { + num_objects: spec.num_objects, + attribute_domains: spec.attribute_domains, + frequency_tables: spec.frequency_tables, + known_values, + }) + } +} + +fn validate_cdft_create( + num_objects: usize, + domains: &[usize], + tables: &[FrequencyTable], + known: &[KnownValue], +) -> Result<(), String> { + for (attribute, &size) in domains.iter().enumerate() { + if size == 0 { + return Err(format!( + "attribute domain size at index {attribute} must be positive" + )); + } + } + let mut pairs = BTreeSet::new(); + for table in tables { + let a = table.attribute_a(); + let b = table.attribute_b(); + if a >= domains.len() || b >= domains.len() { + return Err("frequency table attribute is out of range".into()); + } + if a == b { + return Err("frequency table attributes must be distinct".into()); + } + let pair = if a < b { (a, b) } else { (b, a) }; + if !pairs.insert(pair) { + return Err(format!( + "duplicate frequency table pair ({}, {})", + pair.0, pair.1 + )); + } + if table.counts().len() != domains[a] { + return Err(format!( + "frequency table row count must equal domain size for attribute {a}" + )); + } + if table.counts().iter().any(|row| row.len() != domains[b]) { + return Err(format!( + "frequency table column count must equal domain size for attribute {b}" + )); + } + let total = table + .counts() + .iter() + .flatten() + .try_fold(0usize, |sum, &value| { + sum.checked_add(value) + .ok_or("frequency table count total overflows usize") + })?; + if total != num_objects { + return Err(format!( + "frequency table total {total} must equal num_objects {num_objects}" + )); + } + } + for value in known { + if value.object() >= num_objects { + return Err("known value object is out of range".into()); + } + if value.attribute() >= domains.len() { + return Err("known value attribute is out of range".into()); + } + if value.value() >= domains[value.attribute()] { + return Err("known value is outside the attribute domain".into()); + } + } + Ok(()) +} + impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. pub fn new( @@ -336,7 +435,7 @@ impl Problem for ConsistencyOfDatabaseFrequencyTables { } crate::declare_variants! { - default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects", + default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/grouping_by_swapping.rs b/src/models/misc/grouping_by_swapping.rs index ff6cc0d36..e9f99cb5b 100644 --- a/src/models/misc/grouping_by_swapping.rs +++ b/src/models/misc/grouping_by_swapping.rs @@ -4,7 +4,7 @@ //! whether at most `K` adjacent swaps can transform the string so that every //! symbol appears in a single contiguous block. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Group equal symbols into contiguous blocks using at most K adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "string", type_name: "Vec", description: "Input string over {0, ..., alphabet_size-1}" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number of adjacent swaps allowed" }, - ], + fields: GroupingBySwappingCreateSpec::FIELDS, } } @@ -36,6 +32,54 @@ pub struct GroupingBySwapping { budget: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GroupingBySwappingCreateSpec { + /// Optional alphabet size; omitted values are inferred from the string. + alphabet_size: Option, + /// Input string to group. + #[create(codec = "comma-separated")] + string: Vec, + /// Maximum number of adjacent swaps. + bound: usize, +} + +impl TryFrom for GroupingBySwapping { + type Error = String; + + fn try_from(spec: GroupingBySwappingCreateSpec) -> Result { + let inferred_alphabet_size = spec + .string + .iter() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && !spec.string.is_empty() { + return Err("alphabet size must be positive for a non-empty string".to_string()); + } + if spec.string.is_empty() && spec.bound != 0 { + return Err("bound must be zero when the string is empty".to_string()); + } + + Ok(Self { + alphabet_size, + string: spec.string, + budget: spec.bound, + }) + } +} + impl GroupingBySwapping { /// Create a new GroupingBySwapping instance. /// @@ -160,7 +204,7 @@ impl Problem for GroupingBySwapping { } crate::declare_variants! { - default GroupingBySwapping => "string_len ^ budget", + default GroupingBySwapping => "string_len ^ budget" create GroupingBySwappingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs index be2dbb4bf..bdec8b68d 100644 --- a/src/models/misc/job_shop_scheduling.rs +++ b/src/models/misc/job_shop_scheduling.rs @@ -5,7 +5,7 @@ //! makespan (completion time of the last task) while respecting both within-job //! precedence and single-processor capacity constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,10 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize the makespan of a job-shop schedule", - fields: &[ - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "jobs", type_name: "Vec>", description: "jobs[j][k] = (processor, length) for the k-th task of job j" }, - ], + fields: JobShopSchedulingCreateSpec::FIELDS, } } @@ -32,6 +29,64 @@ pub struct JobShopScheduling { jobs: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct JobShopSchedulingCreateSpec { + /// Jobs expressed as ordered processor-duration operations. + #[create(codec = "semicolon-separated")] + jobs: Vec>, + /// Optional processor count; omitted values are inferred from the jobs. + num_processors: Option, +} + +impl TryFrom for JobShopScheduling { + type Error = String; + + fn try_from(spec: JobShopSchedulingCreateSpec) -> Result { + let inferred_processors = spec + .jobs + .iter() + .flatten() + .map(|(processor, _)| *processor) + .max() + .map(|processor| { + processor + .checked_add(1) + .ok_or_else(|| "inferred processor count overflows usize".to_string()) + }) + .transpose()?; + let num_processors = spec.num_processors.or(inferred_processors).ok_or_else(|| { + "cannot infer processor count from an empty job list; provide num_processors" + .to_string() + })?; + if num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + + for (job_index, job) in spec.jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + if processor >= num_processors { + return Err(format!( + "job {job_index} task {task_index} uses processor {processor}, but num_processors is {num_processors}" + )); + } + } + for (task_index, pair) in job.windows(2).enumerate() { + if pair[0].0 == pair[1].0 { + return Err(format!( + "job {job_index} tasks {task_index} and {} must use different processors", + task_index + 1 + )); + } + } + } + + Ok(Self { + num_processors, + jobs: spec.jobs, + }) + } +} + struct FlattenedTasks { job_task_ids: Vec>, machine_task_ids: Vec>, @@ -234,7 +289,7 @@ impl Problem for JobShopScheduling { } crate::declare_variants! { - default JobShopScheduling => "factorial(num_tasks)", + default JobShopScheduling => "factorial(num_tasks)" create JobShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/knapsack.rs b/src/models/misc/knapsack.rs index dc98f7198..1b268c486 100644 --- a/src/models/misc/knapsack.rs +++ b/src/models/misc/knapsack.rs @@ -3,7 +3,7 @@ //! The 0-1 Knapsack problem asks for a subset of items that maximizes //! total value while respecting a weight capacity constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Select items to maximize total value subject to weight capacity constraint", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Nonnegative item weights w_i" }, - FieldInfo { name: "values", type_name: "Vec", description: "Nonnegative item values v_i" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Nonnegative knapsack capacity C" }, - ], + fields: KnapsackCreateSpec::FIELDS, } } @@ -63,6 +59,33 @@ pub struct Knapsack { capacity: i64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KnapsackCreateSpec { + /// Nonnegative item weights; defaults to one per value. + weights: Option>, + /// Nonnegative item values. + values: Vec, + /// Nonnegative knapsack capacity. + capacity: i64, +} +impl TryFrom for Knapsack { + type Error = String; + fn try_from(spec: KnapsackCreateSpec) -> Result { + let count = spec.values.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal values length".to_string()); + } + if weights.iter().any(|&value| value < 0) + || spec.values.iter().any(|&value| value < 0) + || spec.capacity < 0 + { + return Err("weights, values, and capacity must be nonnegative".to_string()); + } + Ok(Self::new(weights, spec.values, spec.capacity)) + } +} + impl Knapsack { /// Create a new Knapsack instance. /// @@ -163,7 +186,7 @@ impl Problem for Knapsack { } crate::declare_variants! { - default Knapsack => "2^(num_items / 2)", + default Knapsack => "2^(num_items / 2)" create KnapsackCreateSpec, } mod nonnegative_i64 { diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 79b8078b1..49489f939 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -4,7 +4,7 @@ //! at least K distinct m-tuples (one element per set) have total size at least B. //! Garey & Johnson MP10. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Or; use serde::de::Error as _; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Count m-tuples whose total size meets a bound and compare against a threshold K", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "m sets, each containing positive integer sizes" }, - FieldInfo { name: "k", type_name: "u64", description: "Threshold K (answer YES iff count >= K)" }, - FieldInfo { name: "bound", type_name: "u64", description: "Lower bound B on tuple sum" }, - ], + fields: KthLargestMTupleCreateSpec::FIELDS, } } @@ -68,6 +64,24 @@ pub struct KthLargestMTuple { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthLargestMTupleCreateSpec { + /// m sets, each containing positive integer sizes. + subsets: Vec>, + /// Threshold K (answer YES iff count >= K). + k: u64, + /// Lower bound B on tuple sum. + bound: u64, +} + +impl TryFrom for KthLargestMTuple { + type Error = String; + + fn try_from(spec: KthLargestMTupleCreateSpec) -> Result { + Self::try_new(spec.subsets, spec.k, spec.bound) + } +} + impl KthLargestMTuple { fn validate(sets: &[Vec], k: u64, bound: u64) -> Result<(), String> { if sets.is_empty() { @@ -201,7 +215,7 @@ impl Problem for KthLargestMTuple { // Best known: brute-force enumeration of all tuples, O(total_tuples * num_sets). // No sub-exponential exact algorithm is known for the general case. crate::declare_variants! { - default KthLargestMTuple => "total_tuples * num_sets", + default KthLargestMTuple => "total_tuples * num_sets" create KthLargestMTupleCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 7425ad942..20b05e6a3 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -5,7 +5,7 @@ //! `max_length` positions, where each entry is either a valid symbol or the //! padding symbol (`alphabet_size`). Padding must be contiguous at the end. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a longest common subsequence for a set of strings", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "strings", type_name: "Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible subsequence length (min of string lengths)" }, - ], + fields: LongestCommonSubsequenceCreateSpec::FIELDS, } } @@ -45,6 +41,54 @@ pub struct LongestCommonSubsequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCommonSubsequenceCreateSpec { + /// Optional alphabet size; omitted values are inferred from the strings. + alphabet_size: Option, + /// Input strings over the shared alphabet. + #[create(codec = "character-rows")] + strings: Vec>, +} + +impl TryFrom for LongestCommonSubsequence { + type Error = String; + + fn try_from(spec: LongestCommonSubsequenceCreateSpec) -> Result { + if !spec.strings.iter().any(|string| !string.is_empty()) { + return Err("at least one input string must be non-empty".to_string()); + } + let inferred_alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 { + return Err("alphabet size must be positive".to_string()); + } + let max_length = spec.strings.iter().map(Vec::len).min().unwrap_or(0); + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl LongestCommonSubsequence { /// Create a new LongestCommonSubsequence instance. /// @@ -203,7 +247,7 @@ impl Problem for LongestCommonSubsequence { } crate::declare_variants! { - default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length", + default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length" create LongestCommonSubsequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index 453b80089..8fdbe13d3 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -4,7 +4,7 @@ //! that identifies each object with minimum total external path length //! (sum of depths of all leaves). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,11 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find decision tree identifying objects with minimum total path length", - fields: &[ - FieldInfo { name: "test_matrix", type_name: "Vec>", description: "Binary matrix: test_matrix[j][i] = object i passes test j" }, - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects to identify" }, - FieldInfo { name: "num_tests", type_name: "usize", description: "Number of available binary tests" }, - ], + fields: MinimumDecisionTreeCreateSpec::FIELDS, } } @@ -62,6 +58,55 @@ pub struct MinimumDecisionTree { num_tests: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDecisionTreeCreateSpec { + /// Binary test matrix as JSON. + #[create(codec = "json")] + test_matrix: Vec>, + /// Number of objects. + num_objects: usize, + /// Number of tests. + num_tests: usize, +} + +impl TryFrom for MinimumDecisionTree { + type Error = String; + fn try_from(spec: MinimumDecisionTreeCreateSpec) -> Result { + if spec.num_objects < 2 { + return Err("num_objects must be at least 2".into()); + } + if spec.num_tests == 0 { + return Err("num_tests must be positive".into()); + } + if spec.test_matrix.len() != spec.num_tests { + return Err("test_matrix row count must equal num_tests".into()); + } + if spec + .test_matrix + .iter() + .any(|row| row.len() != spec.num_objects) + { + return Err("each test_matrix row must have num_objects columns".into()); + } + for a in 0..spec.num_objects { + for b in a + 1..spec.num_objects { + if !(0..spec.num_tests) + .any(|test| spec.test_matrix[test][a] != spec.test_matrix[test][b]) + { + return Err(format!( + "objects {a} and {b} are not distinguished by any test" + )); + } + } + } + Ok(Self { + test_matrix: spec.test_matrix, + num_objects: spec.num_objects, + num_tests: spec.num_tests, + }) + } +} + impl MinimumDecisionTree { /// Create a new MinimumDecisionTree problem. /// @@ -187,7 +232,7 @@ impl Problem for MinimumDecisionTree { } crate::declare_variants! { - default MinimumDecisionTree => "num_tests^num_objects", + default MinimumDecisionTree => "num_tests^num_objects" create MinimumDecisionTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index f507094d5..a743fcc4c 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -8,7 +8,7 @@ //! - `MinimumTardinessSequencing` — unit-length tasks (`1|prec, pj=1|∑Uj`) //! - `MinimumTardinessSequencing` — arbitrary-length tasks (`1|prec|∑Uj`) -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; use serde::{Deserialize, Serialize}; @@ -21,11 +21,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "One", &["One", "i32"])], module_path: module_path!(), description: "Schedule tasks with precedence constraints and deadlines to minimize the number of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: MinimumTardinessSequencingOneCreateSpec::FIELDS, } } @@ -64,6 +60,64 @@ pub struct MinimumTardinessSequencing { precedences: Vec<(usize, usize)>, } +macro_rules! minimum_tardiness_create_spec { + ($name:ident, $weight:ty, $construct:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + lengths: Vec<$weight>, + deadlines: Vec, + precedences: Option>, + } + + impl TryFrom<$name> for MinimumTardinessSequencing<$weight> { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.lengths.len() != spec.deadlines.len() { + return Err("lengths and deadlines must have the same length".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_tasks = spec.lengths.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_tasks || succ >= num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_tasks} tasks" + )); + } + $construct(spec.lengths, spec.deadlines, precedences) + } + } + }; +} + +minimum_tardiness_create_spec!( + MinimumTardinessSequencingOneCreateSpec, + One, + |lengths: Vec, deadlines, precedences| { + Ok(MinimumTardinessSequencing::new( + lengths.len(), + deadlines, + precedences, + )) + } +); +minimum_tardiness_create_spec!( + MinimumTardinessSequencingI32CreateSpec, + i32, + |lengths: Vec, deadlines, precedences| { + if lengths.iter().any(|&length| length <= 0) { + return Err("all task lengths must be positive".to_string()); + } + Ok(MinimumTardinessSequencing::with_lengths( + lengths, + deadlines, + precedences, + )) + } +); + impl MinimumTardinessSequencing { /// Create a new unit-length MinimumTardinessSequencing instance. /// @@ -247,8 +301,8 @@ impl Problem for MinimumTardinessSequencing { } crate::declare_variants! { - default MinimumTardinessSequencing => "2^num_tasks", - MinimumTardinessSequencing => "2^num_tasks", + default MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingOneCreateSpec, + MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingI32CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_weight_and_or_graph.rs b/src/models/misc/minimum_weight_and_or_graph.rs index dc497a6f5..662fc3595 100644 --- a/src/models/misc/minimum_weight_and_or_graph.rs +++ b/src/models/misc/minimum_weight_and_or_graph.rs @@ -3,7 +3,7 @@ //! Given a directed acyclic graph with AND/OR gates, find the minimum-weight //! solution subgraph from a designated source vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Deserializer, Serialize}; @@ -16,13 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find the minimum-weight solution subgraph from a source in a DAG with AND/OR gates", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the DAG" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Directed arcs (u, v)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex index" }, - FieldInfo { name: "gate_types", type_name: "Vec>", description: "Gate type per vertex: Some(true)=AND, Some(false)=OR, None=leaf" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Weight of each arc" }, - ], + fields: MinimumWeightAndOrGraphCreateSpec::FIELDS, } } @@ -78,6 +72,56 @@ pub struct MinimumWeightAndOrGraph { outgoing: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumWeightAndOrGraphCreateSpec { + /// Number of vertices in the DAG. + num_vertices: usize, + /// Directed arcs. + arcs: Vec<(usize, usize)>, + /// Source vertex. + source: usize, + /// Gate type per vertex. + gate_types: Vec>, + /// Arc weights; defaults to one per arc. + arc_weights: Option>, +} +impl TryFrom for MinimumWeightAndOrGraph { + type Error = String; + fn try_from(spec: MinimumWeightAndOrGraphCreateSpec) -> Result { + if spec.source >= spec.num_vertices { + return Err("source is outside the graph".to_string()); + } + if spec.gate_types.len() != spec.num_vertices { + return Err("gate_types length must equal num_vertices".to_string()); + } + if spec.gate_types[spec.source].is_none() { + return Err("source must be an AND or OR gate".to_string()); + } + if let Some(&(u, v)) = spec + .arcs + .iter() + .find(|&&(u, v)| u >= spec.num_vertices || v >= spec.num_vertices) + { + return Err(format!("arc ({u}, {v}) is out of bounds")); + } + let count = spec.arcs.len(); + let arc_weights = spec.arc_weights.unwrap_or_else(|| vec![1; count]); + if arc_weights.len() != count { + return Err(format!( + "arc_weights has {} entries, expected {count}", + arc_weights.len() + )); + } + Ok(Self::new( + spec.num_vertices, + spec.arcs, + spec.source, + spec.gate_types, + arc_weights, + )) + } +} + #[derive(Deserialize)] struct MinimumWeightAndOrGraphData { num_vertices: usize, @@ -295,7 +339,7 @@ impl Problem for MinimumWeightAndOrGraph { } crate::declare_variants! { - default MinimumWeightAndOrGraph => "2^num_arcs", + default MinimumWeightAndOrGraph => "2^num_arcs" create MinimumWeightAndOrGraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/multiprocessor_scheduling.rs b/src/models/misc/multiprocessor_scheduling.rs index 4339a99ce..65d9ff2ca 100644 --- a/src/models/misc/multiprocessor_scheduling.rs +++ b/src/models/misc/multiprocessor_scheduling.rs @@ -4,7 +4,7 @@ //! can be assigned to identical processors such that no processor's //! total load exceeds a given deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign tasks to processors so that no processor's load exceeds a deadline", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadline", type_name: "u64", description: "Global deadline D" }, - ], + fields: MultiprocessorSchedulingCreateSpec::FIELDS, } } @@ -63,6 +59,25 @@ pub struct MultiprocessorScheduling { deadline: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultiprocessorSchedulingCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Number of identical processors. + num_processors: usize, + /// Global deadline. + deadline: u64, +} +impl TryFrom for MultiprocessorScheduling { + type Error = String; + fn try_from(spec: MultiprocessorSchedulingCreateSpec) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + Ok(Self::new(spec.lengths, spec.num_processors, spec.deadline)) + } +} + impl MultiprocessorScheduling { /// Create a new Multiprocessor Scheduling instance. /// @@ -134,7 +149,7 @@ impl Problem for MultiprocessorScheduling { } crate::declare_variants! { - default MultiprocessorScheduling => "2^num_tasks", + default MultiprocessorScheduling => "2^num_tasks" create MultiprocessorSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 3db688a38..9cbb33bcc 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -6,7 +6,7 @@ //! both machine capacity (one job at a time per machine) and job capacity //! (each job uses at most one machine at a time) constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,10 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize the makespan of an open-shop schedule", - fields: &[ - FieldInfo { name: "num_machines", type_name: "usize", description: "Number of machines m" }, - FieldInfo { name: "processing_times", type_name: "Vec>", description: "processing_times[j][i] = processing time of job j on machine i (n x m)" }, - ], + fields: OpenShopSchedulingCreateSpec::FIELDS, } } @@ -69,6 +66,31 @@ pub struct OpenShopScheduling { processing_times: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OpenShopSchedulingCreateSpec { + /// Number of machines m. + num_processors: usize, + /// Processing time of each job on each machine (n x m). + processing_times: Vec>, +} + +impl TryFrom for OpenShopScheduling { + type Error = String; + + fn try_from(spec: OpenShopSchedulingCreateSpec) -> Result { + for (job, times) in spec.processing_times.iter().enumerate() { + if times.len() != spec.num_processors { + return Err(format!( + "processing_times[{job}] has {} entries, expected {}", + times.len(), + spec.num_processors + )); + } + } + Ok(Self::new(spec.num_processors, spec.processing_times)) + } +} + impl OpenShopScheduling { /// Create a new Open Shop Scheduling instance. /// @@ -222,7 +244,7 @@ impl Problem for OpenShopScheduling { } crate::declare_variants! { - default OpenShopScheduling => "factorial(num_jobs)^num_machines", + default OpenShopScheduling => "factorial(num_jobs)^num_machines" create OpenShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/optimum_communication_spanning_tree.rs b/src/models/misc/optimum_communication_spanning_tree.rs index 6f2729b9a..41c0f7631 100644 --- a/src/models/misc/optimum_communication_spanning_tree.rs +++ b/src/models/misc/optimum_communication_spanning_tree.rs @@ -5,7 +5,7 @@ //! minimizes the total communication cost: sum_{u>", description: "Symmetric weight matrix w(i,j)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Symmetric requirement matrix r(i,j)" }, - ], + fields: OptimumCommunicationSpanningTreeCreateSpec::FIELDS, } } @@ -72,6 +68,49 @@ pub struct OptimumCommunicationSpanningTree { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OptimumCommunicationSpanningTreeCreateSpec { + /// Number of vertices. + num_vertices: usize, + /// Symmetric weight matrix; defaults to unit off-diagonal weights. + edge_weights: Option>>, + /// Symmetric communication requirement matrix. + requirements: Vec>, +} +impl TryFrom for OptimumCommunicationSpanningTree { + type Error = String; + fn try_from(spec: OptimumCommunicationSpanningTreeCreateSpec) -> Result { + let n = spec.num_vertices; + if n < 2 { + return Err("must have at least two vertices".to_string()); + } + let edge_weights = spec.edge_weights.unwrap_or_else(|| { + (0..n) + .map(|i| (0..n).map(|j| i32::from(i != j)).collect()) + .collect() + }); + for (name, matrix) in [ + ("edge_weights", &edge_weights), + ("requirements", &spec.requirements), + ] { + if matrix.len() != n || matrix.iter().any(|row| row.len() != n) { + return Err(format!("{name} must be a {n} x {n} matrix")); + } + for (i, row) in matrix.iter().enumerate() { + if row[i] != 0 { + return Err(format!("{name} diagonal must be zero")); + } + for (j, &value) in row.iter().enumerate().skip(i + 1) { + if value != matrix[j][i] || value < 0 { + return Err(format!("{name} must be symmetric and nonnegative")); + } + } + } + } + Ok(Self::new(edge_weights, spec.requirements)) + } +} + impl OptimumCommunicationSpanningTree { /// Create a new OptimumCommunicationSpanningTree instance. /// @@ -312,7 +351,7 @@ impl Problem for OptimumCommunicationSpanningTree { } crate::declare_variants! { - default OptimumCommunicationSpanningTree => "2^num_edges", + default OptimumCommunicationSpanningTree => "2^num_edges" create OptimumCommunicationSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/partially_ordered_knapsack.rs b/src/models/misc/partially_ordered_knapsack.rs index 405dab450..e70e3be46 100644 --- a/src/models/misc/partially_ordered_knapsack.rs +++ b/src/models/misc/partially_ordered_knapsack.rs @@ -4,7 +4,7 @@ //! an item requires including all its predecessors (downward-closed set). //! NP-complete in the strong sense (Garey & Johnson, A6 MP12). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Select items to maximize total value subject to precedence constraints and weight capacity", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Item weights w(u) for each item" }, - FieldInfo { name: "values", type_name: "Vec", description: "Item values v(u) for each item" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (a, b) meaning a must be included before b" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Knapsack capacity B" }, - ], + fields: PartiallyOrderedKnapsackCreateSpec::FIELDS, } } @@ -76,6 +71,69 @@ pub struct PartiallyOrderedKnapsack { predecessors: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartiallyOrderedKnapsackCreateSpec { + weights: Vec, + values: Vec, + precedences: Option>, + capacity: i64, +} + +impl TryFrom for PartiallyOrderedKnapsack { + type Error = String; + + fn try_from(spec: PartiallyOrderedKnapsackCreateSpec) -> Result { + if spec.weights.len() != spec.values.len() { + return Err("weights and values must have the same length".to_string()); + } + if spec.capacity < 0 { + return Err("capacity must be non-negative".to_string()); + } + if let Some((index, weight)) = spec + .weights + .iter() + .enumerate() + .find(|(_, weight)| **weight < 0) + { + return Err(format!( + "weight[{index}] must be non-negative, got {weight}" + )); + } + if let Some((index, value)) = spec + .values + .iter() + .enumerate() + .find(|(_, value)| **value < 0) + { + return Err(format!("value[{index}] must be non-negative, got {value}")); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_items = spec.weights.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_items || succ >= num_items) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_items} items" + )); + } + let predecessors = Self::compute_predecessors(&precedences, num_items); + if let Some(item) = predecessors + .iter() + .enumerate() + .find_map(|(item, preds)| preds.contains(&item).then_some(item)) + { + return Err(format!("precedences contain a cycle involving item {item}")); + } + Ok(Self::new( + spec.weights, + spec.values, + precedences, + spec.capacity, + )) + } +} + impl Serialize for PartiallyOrderedKnapsack { fn serialize(&self, serializer: S) -> Result { PartiallyOrderedKnapsackRaw { @@ -266,7 +324,7 @@ impl Problem for PartiallyOrderedKnapsack { } crate::declare_variants! { - default PartiallyOrderedKnapsack => "2^num_items", + default PartiallyOrderedKnapsack => "2^num_items" create PartiallyOrderedKnapsackCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index e5c887e07..b46dcc5f5 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -4,7 +4,7 @@ //! deadline D, determine whether all tasks can be scheduled to meet D while //! respecting precedences. NP-complete via reduction from 3SAT (Ullman, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,12 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule unit-length tasks on m processors by deadline D respecting precedence constraints", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks n = |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "deadline", type_name: "usize", description: "Global deadline D" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (i, j) meaning task i must finish before task j starts" }, - ], + fields: PrecedenceConstrainedSchedulingCreateSpec::FIELDS, } } @@ -58,6 +53,43 @@ pub struct PrecedenceConstrainedScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrecedenceConstrainedSchedulingCreateSpec { + num_tasks: usize, + num_processors: usize, + deadline: usize, + precedences: Option>, +} + +impl TryFrom for PrecedenceConstrainedScheduling { + type Error = String; + + fn try_from(spec: PrecedenceConstrainedSchedulingCreateSpec) -> Result { + if spec.num_tasks > 0 && spec.num_processors == 0 { + return Err("num_processors must be positive when there are tasks".to_string()); + } + if spec.num_tasks > 0 && spec.deadline == 0 { + return Err("deadline must be positive when there are tasks".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= spec.num_tasks || succ >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadline, + precedences, + )) + } +} + impl PrecedenceConstrainedScheduling { /// Create a new Precedence Constrained Scheduling instance. /// @@ -157,7 +189,7 @@ impl Problem for PrecedenceConstrainedScheduling { } crate::declare_variants! { - default PrecedenceConstrainedScheduling => "2^num_tasks", + default PrecedenceConstrainedScheduling => "2^num_tasks" create PrecedenceConstrainedSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/preemptive_scheduling.rs b/src/models/misc/preemptive_scheduling.rs index cad0d78ee..fbe1d98e3 100644 --- a/src/models/misc/preemptive_scheduling.rs +++ b/src/models/misc/preemptive_scheduling.rs @@ -5,7 +5,7 @@ //! `m` identical processors, subject to precedence constraints. //! The goal is to minimize the makespan (latest completion time). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize makespan for preemptive parallel-processor scheduling with precedence constraints", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (pred, succ) — pred must finish before succ starts" }, - ], + fields: PreemptiveSchedulingCreateSpec::FIELDS, } } @@ -68,6 +64,23 @@ pub struct PreemptiveScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PreemptiveSchedulingCreateSpec { + lengths: Vec, + num_processors: usize, + precedences: Option>, +} + +impl TryFrom for PreemptiveScheduling { + type Error = String; + + fn try_from(spec: PreemptiveSchedulingCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, spec.num_processors, &precedences)?; + Ok(Self::new(spec.lengths, spec.num_processors, precedences)) + } +} + #[derive(Deserialize)] struct PreemptiveSchedulingSerde { lengths: Vec, @@ -244,7 +257,7 @@ impl Problem for PreemptiveScheduling { } crate::declare_variants! { - default PreemptiveScheduling => "2^(num_tasks * num_tasks)", + default PreemptiveScheduling => "2^(num_tasks * num_tasks)" create PreemptiveSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/production_planning.rs b/src/models/misc/production_planning.rs index 914df0d3b..e670d35e5 100644 --- a/src/models/misc/production_planning.rs +++ b/src/models/misc/production_planning.rs @@ -5,7 +5,7 @@ //! exists a feasible production plan that satisfies all demand without //! backlogging and stays within budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Or; use serde::{Deserialize, Serialize}; @@ -18,15 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether a multi-period production plan can satisfy all demand within a cost bound", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of planning periods n" }, - FieldInfo { name: "demands", type_name: "Vec", description: "Demand r_i for each period" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Production capacity c_i for each period" }, - FieldInfo { name: "setup_costs", type_name: "Vec", description: "Setup cost b_i incurred when x_i > 0" }, - FieldInfo { name: "production_costs", type_name: "Vec", description: "Per-unit production cost coefficient p_i" }, - FieldInfo { name: "inventory_costs", type_name: "Vec", description: "Per-unit inventory cost coefficient h_i" }, - FieldInfo { name: "cost_bound", type_name: "u64", description: "Total cost bound B" }, - ], + fields: ProductionPlanningCreateSpec::FIELDS, } } @@ -42,6 +34,63 @@ pub struct ProductionPlanning { cost_bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ProductionPlanningCreateSpec { + /// Number of planning periods. + num_periods: usize, + /// Demand per period. + demands: Vec, + /// Production capacity per period. + capacities: Vec, + /// Setup cost per period. + setup_costs: Vec, + /// Per-unit production cost per period. + production_costs: Vec, + /// Per-unit inventory cost per period. + inventory_costs: Vec, + /// Total cost bound. + cost_bound: u64, +} +impl TryFrom for ProductionPlanning { + type Error = String; + fn try_from(spec: ProductionPlanningCreateSpec) -> Result { + if spec.num_periods == 0 { + return Err("num_periods must be positive".to_string()); + } + for (name, len) in [ + ("demands", spec.demands.len()), + ("capacities", spec.capacities.len()), + ("setup_costs", spec.setup_costs.len()), + ("production_costs", spec.production_costs.len()), + ("inventory_costs", spec.inventory_costs.len()), + ] { + if len != spec.num_periods { + return Err(format!( + "{name} has {len} entries, expected {}", + spec.num_periods + )); + } + } + if spec.capacities.iter().any(|&capacity| { + usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + }) { + return Err("capacities must fit in usize for dims()".to_string()); + } + Ok(Self::new( + spec.num_periods, + spec.demands, + spec.capacities, + spec.setup_costs, + spec.production_costs, + spec.inventory_costs, + spec.cost_bound, + )) + } +} + impl ProductionPlanning { pub fn new( num_periods: usize, @@ -185,7 +234,7 @@ impl Problem for ProductionPlanning { } crate::declare_variants! { - default ProductionPlanning => "(max_capacity + 1)^num_periods", + default ProductionPlanning => "(max_capacity + 1)^num_periods" create ProductionPlanningCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs index 15c6d1895..24ac4fcb0 100644 --- a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -6,7 +6,7 @@ //! completion time. Within each processor, tasks are ordered by Smith's //! rule (non-decreasing length-to-weight ratio). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,11 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign tasks to processors to minimize total weighted completion time (Smith's rule ordering)", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - ], + fields: SchedulingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -65,6 +61,34 @@ pub struct SchedulingToMinimizeWeightedCompletionTime { num_processors: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingToMinimizeWeightedCompletionTimeCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Number of identical processors. + num_processors: usize, +} +impl TryFrom + for SchedulingToMinimizeWeightedCompletionTime +{ + type Error = String; + fn try_from( + spec: SchedulingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + let count = spec.lengths.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.num_processors)) + } +} + fn serialize_num_processors(v: &usize, s: S) -> Result { s.serialize_u64(*v as u64) } @@ -222,7 +246,7 @@ impl Problem for SchedulingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks", + default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks" create SchedulingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/scheduling_with_individual_deadlines.rs b/src/models/misc/scheduling_with_individual_deadlines.rs index 391ca772b..f48c82a06 100644 --- a/src/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/models/misc/scheduling_with_individual_deadlines.rs @@ -4,7 +4,7 @@ //! determine whether they can be scheduled on `m` identical processors so that //! every task finishes by its own deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether unit-length tasks can be scheduled on m processors while meeting individual deadlines", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SchedulingWithIndividualDeadlinesCreateSpec::FIELDS, } } @@ -40,6 +35,46 @@ pub struct SchedulingWithIndividualDeadlines { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingWithIndividualDeadlinesCreateSpec { + /// Number of tasks. + num_tasks: usize, + /// Number of identical processors. + num_processors: usize, + /// Deadline for each task. + deadlines: Vec, + /// Precedence pairs. + precedences: Option>, +} +impl TryFrom for SchedulingWithIndividualDeadlines { + type Error = String; + fn try_from(spec: SchedulingWithIndividualDeadlinesCreateSpec) -> Result { + if spec.deadlines.len() != spec.num_tasks { + return Err(format!( + "deadlines has {} entries, expected {}", + spec.deadlines.len(), + spec.num_tasks + )); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(p, s)| p >= spec.num_tasks || s >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadlines, + precedences, + )) + } +} + impl SchedulingWithIndividualDeadlines { pub fn new( num_tasks: usize, @@ -145,7 +180,7 @@ impl Problem for SchedulingWithIndividualDeadlines { } crate::declare_variants! { - default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks", + default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks" create SchedulingWithIndividualDeadlinesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index 46627bfcf..b34045d65 100644 --- a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -4,7 +4,7 @@ //! a valid one-machine schedule that minimizes the maximum cumulative cost //! over all prefixes. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::de::Error as _; use serde::{Deserialize, Serialize}; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with precedence constraints to minimize the maximum cumulative cost prefix", - fields: &[ - FieldInfo { name: "costs", type_name: "Vec", description: "Task costs in schedule order-independent indexing" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingCumulativeCostCreateSpec::FIELDS, } } @@ -40,6 +37,30 @@ pub struct SequencingToMinimizeMaximumCumulativeCost { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingCumulativeCostCreateSpec { + /// Task costs. + #[create(codec = "comma-separated")] + costs: Vec, + /// Precedence arcs; omitted means no constraints. + #[create(codec = "arc-list")] + precedences: Option>, +} + +impl TryFrom for SequencingToMinimizeMaximumCumulativeCost { + type Error = String; + fn try_from(spec: SequencingCumulativeCostCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + if let Some(message) = precedence_validation_error(&precedences, spec.costs.len()) { + return Err(message); + } + Ok(Self { + costs: spec.costs, + precedences, + }) + } +} + #[derive(Debug, Deserialize)] struct SequencingToMinimizeMaximumCumulativeCostUnchecked { costs: Vec, @@ -165,7 +186,7 @@ impl Problem for SequencingToMinimizeMaximumCumulativeCost { } crate::declare_variants! { - default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)", + default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)" create SequencingCumulativeCostCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index 379dac87d..3bd89bba6 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -4,7 +4,7 @@ //! Garey & Johnson, 1979) where tasks with processing times, weights, //! and deadlines must be scheduled to minimize the total weight of tardy tasks. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,11 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with lengths, weights, and deadlines to minimize total weight of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - ], + fields: SequencingToMinimizeTardyTaskWeightCreateSpec::FIELDS, } } @@ -44,6 +40,32 @@ pub struct SequencingToMinimizeTardyTaskWeight { deadlines: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeTardyTaskWeightCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Deadline for each task. + deadlines: Vec, +} +impl TryFrom + for SequencingToMinimizeTardyTaskWeight +{ + type Error = String; + fn try_from(spec: SequencingToMinimizeTardyTaskWeightCreateSpec) -> Result { + let count = spec.lengths.len(); + if spec.deadlines.len() != count { + return Err("deadlines length must equal lengths length".to_string()); + } + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.deadlines)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeTardyTaskWeightSerde { lengths: Vec, @@ -166,7 +188,7 @@ impl Problem for SequencingToMinimizeTardyTaskWeight { } crate::declare_variants! { - default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)", + default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)" create SequencingToMinimizeTardyTaskWeightCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 6a62e11eb..4390ecbe6 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -10,7 +10,7 @@ //! Optimal Linear Arrangement, which uses zero-length edge jobs instead //! of padding them to unit length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -23,11 +23,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with lengths, weights, and precedence constraints to minimize total weighted completion time", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -46,6 +42,27 @@ pub struct SequencingToMinimizeWeightedCompletionTime { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: Vec, + weights: Vec, + precedences: Option>, +} + +impl TryFrom + for SequencingToMinimizeWeightedCompletionTime +{ + type Error = String; + + fn try_from( + spec: SequencingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, &spec.weights, &precedences)?; + Ok(Self::new(spec.lengths, spec.weights, precedences)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeWeightedCompletionTimeSerde { lengths: Vec, @@ -215,7 +232,7 @@ impl Problem for SequencingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)", + default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)" create SequencingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 946ae2140..46d2c0aa7 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -5,7 +5,7 @@ //! total weighted tardiness is at most a given bound. //! Corresponds to scheduling notation `1 || sum w_j T_j`. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule jobs on one machine so total weighted tardiness is at most K", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing times l_j for each job" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Tardiness weights w_j for each job" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines d_j for each job" }, - FieldInfo { name: "bound", type_name: "u64", description: "Upper bound K on total weighted tardiness" }, - ], + fields: SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS, } } @@ -63,6 +58,39 @@ pub struct SequencingToMinimizeWeightedTardiness { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedTardinessCreateSpec { + /// Processing times for each job. + lengths: Vec, + /// Tardiness weights for each job. + weights: Vec, + /// Deadlines for each job. + deadlines: Vec, + /// Upper bound on total weighted tardiness. + bound: u64, +} +impl TryFrom + for SequencingToMinimizeWeightedTardiness +{ + type Error = String; + fn try_from( + spec: SequencingToMinimizeWeightedTardinessCreateSpec, + ) -> Result { + if spec.lengths.len() != spec.weights.len() { + return Err("weights length must equal lengths length".to_string()); + } + if spec.lengths.len() != spec.deadlines.len() { + return Err("deadlines length must equal lengths length".to_string()); + } + Ok(Self::new( + spec.lengths, + spec.weights, + spec.deadlines, + spec.bound, + )) + } +} + impl SequencingToMinimizeWeightedTardiness { /// Create a new weighted tardiness scheduling instance. /// @@ -159,7 +187,7 @@ impl Problem for SequencingToMinimizeWeightedTardiness { } crate::declare_variants! { - default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)", + default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)" create SequencingToMinimizeWeightedTardinessCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index cc391444a..8534f5019 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -4,7 +4,7 @@ //! determine whether all tasks can be scheduled non-overlappingly such that each //! task runs entirely within its allowed time window. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks non-overlappingly within their time windows", - fields: &[ - FieldInfo { name: "release_times", type_name: "Vec", description: "Release time r(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - ], + fields: SequencingWithinIntervalsCreateSpec::FIELDS, } } @@ -63,6 +59,36 @@ pub struct SequencingWithinIntervals { lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingWithinIntervalsCreateSpec { + /// Release times. + release_times: Vec, + /// Deadlines. + deadlines: Vec, + /// Processing lengths. + lengths: Vec, +} +impl TryFrom for SequencingWithinIntervals { + type Error = String; + fn try_from(spec: SequencingWithinIntervalsCreateSpec) -> Result { + if spec.release_times.len() != spec.deadlines.len() { + return Err("release_times and deadlines must have the same length".to_string()); + } + if spec.release_times.len() != spec.lengths.len() { + return Err("release_times and lengths must have the same length".to_string()); + } + for index in 0..spec.release_times.len() { + let finish = spec.release_times[index] + .checked_add(spec.lengths[index]) + .ok_or_else(|| format!("task {index} release time plus length overflows u64"))?; + if finish > spec.deadlines[index] { + return Err(format!("task {index} has an empty time window")); + } + } + Ok(Self::new(spec.release_times, spec.deadlines, spec.lengths)) + } +} + impl SequencingWithinIntervals { /// Create a new SequencingWithinIntervals problem. /// @@ -173,7 +199,7 @@ impl Problem for SequencingWithinIntervals { } crate::declare_variants! { - default SequencingWithinIntervals => "2^num_tasks", + default SequencingWithinIntervals => "2^num_tasks" create SequencingWithinIntervalsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/shortest_common_supersequence.rs b/src/models/misc/shortest_common_supersequence.rs index b6d6bafee..03204134f 100644 --- a/src/models/misc/shortest_common_supersequence.rs +++ b/src/models/misc/shortest_common_supersequence.rs @@ -12,7 +12,7 @@ //! lengths (the worst case where no overlap exists). This problem is NP-hard //! (Maier, 1978). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -25,11 +25,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a shortest common supersequence for a set of strings", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "strings", type_name: "Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible supersequence length (sum of all string lengths)" }, - ], + fields: ShortestCommonSupersequenceCreateSpec::FIELDS, } } @@ -65,6 +61,48 @@ pub struct ShortestCommonSupersequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestCommonSupersequenceCreateSpec { + /// Input strings; the alphabet and maximum length are inferred from them. + #[create(codec = "semicolon-separated")] + strings: Vec>, +} + +impl TryFrom for ShortestCommonSupersequence { + type Error = String; + + fn try_from(spec: ShortestCommonSupersequenceCreateSpec) -> Result { + if spec.strings.is_empty() { + return Err("must have at least one string".to_string()); + } + + let alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let max_length = spec.strings.iter().try_fold(0_usize, |total, string| { + total + .checked_add(string.len()) + .ok_or_else(|| "maximum supersequence length overflows usize".to_string()) + })?; + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl ShortestCommonSupersequence { /// Create a new ShortestCommonSupersequence instance. /// @@ -179,7 +217,7 @@ impl Problem for ShortestCommonSupersequence { } crate::declare_variants! { - default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length", + default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length" create ShortestCommonSupersequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index e1b835d28..453266759 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -4,7 +4,7 @@ //! walk that traverses every required arc in some order and minimizes the //! total route length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,13 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a closed walk that traverses each required directed arc and minimizes total length", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the mixed graph" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Required directed arcs that must be traversed" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Undirected edges available for connector paths" }, - FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Nonnegative lengths of the required directed arcs" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Nonnegative lengths of the undirected connector edges" }, - ], + fields: StackerCraneCreateSpec::FIELDS, } } @@ -46,6 +40,83 @@ pub struct StackerCrane { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StackerCraneCreateSpec { + /// Required directed arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Undirected connector edges. + #[create(name = "graph", codec = "edge-list")] + edges: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Required-arc lengths; defaults to one per arc. + #[create(codec = "comma-separated")] + arc_lengths: Option>, + /// Connector-edge lengths; defaults to one per edge. + #[create(codec = "comma-separated")] + edge_lengths: Option>, +} + +impl TryFrom for StackerCrane { + type Error = String; + + fn try_from(spec: StackerCraneCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.edges.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred_arcs = inferred_vertex_count(&spec.arcs)?; + let inferred_edges = inferred_vertex_count(&spec.edges)?; + let num_vertices = match spec.num_vertices { + Some(count) => count, + None if inferred_arcs == inferred_edges => inferred_arcs, + None => { + return Err(format!( + "directed and undirected inputs infer different vertex counts ({inferred_arcs} and {inferred_edges}); provide num_vertices" + )) + } + }; + if num_vertices < inferred_arcs || num_vertices < inferred_edges { + return Err(format!( + "num_vertices {num_vertices} is too small for the provided endpoints" + )); + } + let arc_lengths = spec.arc_lengths.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let edge_lengths = spec + .edge_lengths + .unwrap_or_else(|| vec![1; spec.edges.len()]); + Self::try_new( + num_vertices, + spec.arcs, + spec.edges, + arc_lengths, + edge_lengths, + ) + } +} + +fn inferred_vertex_count(pairs: &[(usize, usize)]) -> Result { + pairs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| { + vertex + .checked_add(1) + .ok_or("vertex count overflows usize".to_string()) + }) + .transpose() + .map(|count| count.unwrap_or(0)) +} + impl StackerCrane { /// Create a new Stacker Crane instance. /// @@ -267,7 +338,7 @@ impl Problem for StackerCrane { } crate::declare_variants! { - default StackerCrane => "num_vertices^2 * 2^num_arcs", + default StackerCrane => "num_vertices^2 * 2^num_arcs" create StackerCraneCreateSpec, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/models/misc/staff_scheduling.rs b/src/models/misc/staff_scheduling.rs index 7db6f75be..990063e77 100644 --- a/src/models/misc/staff_scheduling.rs +++ b/src/models/misc/staff_scheduling.rs @@ -4,7 +4,7 @@ //! worker budget, determine whether workers can be assigned to schedules so that //! all requirements are met without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,12 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign workers to schedule patterns to satisfy per-period staffing requirements within a worker budget", - fields: &[ - FieldInfo { name: "shifts_per_schedule", type_name: "usize", description: "Required number of active periods in each schedule pattern" }, - FieldInfo { name: "schedules", type_name: "Vec>", description: "Binary schedule patterns available to workers" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Minimum staffing requirement for each period" }, - FieldInfo { name: "num_workers", type_name: "u64", description: "Maximum number of workers available" }, - ], + fields: StaffSchedulingCreateSpec::FIELDS, } } @@ -38,6 +33,50 @@ pub struct StaffScheduling { num_workers: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StaffSchedulingCreateSpec { + /// Required number of active periods in each schedule pattern. + k: usize, + /// Binary schedule patterns available to workers. + schedules: Vec>, + /// Minimum staffing requirement for each period. + requirements: Vec, + /// Maximum number of workers available. + num_workers: u64, +} + +impl TryFrom for StaffScheduling { + type Error = String; + + fn try_from(spec: StaffSchedulingCreateSpec) -> Result { + if spec.num_workers >= usize::MAX as u64 { + return Err("num_workers must be smaller than usize::MAX".to_string()); + } + for (schedule_index, schedule) in spec.schedules.iter().enumerate() { + if schedule.len() != spec.requirements.len() { + return Err(format!( + "schedules[{schedule_index}] has {} periods, expected {}", + schedule.len(), + spec.requirements.len() + )); + } + let active_periods = schedule.iter().filter(|&&active| active).count(); + if active_periods != spec.k { + return Err(format!( + "schedules[{schedule_index}] has {active_periods} active periods, expected {}", + spec.k + )); + } + } + Ok(Self::new( + spec.k, + spec.schedules, + spec.requirements, + spec.num_workers, + )) + } +} + impl StaffScheduling { /// Create a new Staff Scheduling instance. /// @@ -173,7 +212,7 @@ impl Problem for StaffScheduling { } crate::declare_variants! { - default StaffScheduling => "(num_workers + 1)^num_schedules", + default StaffScheduling => "(num_workers + 1)^num_schedules" create StaffSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/string_to_string_correction.rs b/src/models/misc/string_to_string_correction.rs index 30f02a3d0..f884cc522 100644 --- a/src/models/misc/string_to_string_correction.rs +++ b/src/models/misc/string_to_string_correction.rs @@ -14,7 +14,7 @@ //! //! This problem is NP-complete (Wagner, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -26,12 +26,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Derive target string from source using at most K deletions and adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the finite alphabet" }, - FieldInfo { name: "source", type_name: "Vec", description: "Source string (symbol indices)" }, - FieldInfo { name: "target", type_name: "Vec", description: "Target string (symbol indices)" }, - FieldInfo { name: "bound", type_name: "usize", description: "Maximum number of operations allowed" }, - ], + fields: StringToStringCorrectionCreateSpec::FIELDS, } } @@ -77,6 +72,59 @@ pub struct StringToStringCorrection { bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StringToStringCorrectionCreateSpec { + /// Optional alphabet size; omitted values are inferred from both strings. + alphabet_size: Option, + /// Source string. + #[create(codec = "comma-separated")] + source_string: Vec, + /// Target string. + #[create(codec = "comma-separated")] + target_string: Vec, + /// Maximum number of correction operations. + bound: usize, +} + +impl TryFrom for StringToStringCorrection { + type Error = String; + + fn try_from(spec: StringToStringCorrectionCreateSpec) -> Result { + let inferred_alphabet_size = spec + .source_string + .iter() + .chain(&spec.target_string) + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && (!spec.source_string.is_empty() || !spec.target_string.is_empty()) + { + return Err( + "alphabet size must be positive when either string is non-empty".to_string(), + ); + } + + Ok(Self { + alphabet_size, + source: spec.source_string, + target: spec.target_string, + bound: spec.bound, + }) + } +} + impl StringToStringCorrection { /// Create a new StringToStringCorrection instance. /// @@ -191,7 +239,7 @@ impl Problem for StringToStringCorrection { } crate::declare_variants! { - default StringToStringCorrection => "(2 * source_length + 1) ^ bound", + default StringToStringCorrection => "(2 * source_length + 1) ^ bound" create StringToStringCorrectionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/three_partition.rs b/src/models/misc/three_partition.rs index 9131544d4..47c3f26e9 100644 --- a/src/models/misc/three_partition.rs +++ b/src/models/misc/three_partition.rs @@ -3,7 +3,7 @@ //! Given 3m positive integers that each lie strictly between B/4 and B/2, //! determine whether they can be partitioned into m triples that all sum to B. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Or; use serde::de::Error as _; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Partition 3m bounded positive integers into m triples whose sums all equal B", - fields: &[ - FieldInfo { name: "sizes", type_name: "Vec", description: "Positive integer sizes s(a) for each element a in A" }, - FieldInfo { name: "bound", type_name: "u64", description: "Target sum B for each triple" }, - ], + fields: ThreePartitionCreateSpec::FIELDS, } } @@ -135,19 +132,30 @@ impl ThreePartition { } } -#[derive(Deserialize)] -struct ThreePartitionData { +#[derive(Deserialize, crate::CreateSpec)] +struct ThreePartitionCreateSpec { + /// Positive integer sizes for the elements to partition. + #[create(codec = "comma-separated")] sizes: Vec, + /// Target sum for each triple. bound: u64, } +impl TryFrom for ThreePartition { + type Error = String; + + fn try_from(spec: ThreePartitionCreateSpec) -> Result { + Self::try_new(spec.sizes, spec.bound) + } +} + impl<'de> Deserialize<'de> for ThreePartition { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let data = ThreePartitionData::deserialize(deserializer)?; - Self::try_new(data.sizes, data.bound).map_err(D::Error::custom) + let spec = ThreePartitionCreateSpec::deserialize(deserializer)?; + Self::try_from(spec).map_err(D::Error::custom) } } @@ -176,7 +184,7 @@ impl Problem for ThreePartition { } crate::declare_variants! { - default ThreePartition => "3^num_elements", + default ThreePartition => "3^num_elements" create ThreePartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index 1235d53b1..94f687ac5 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -4,7 +4,7 @@ //! respecting availability, per-period exclusivity, and exact pairwise work //! requirements. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,14 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign craftsmen to tasks over work periods subject to availability and exact pairwise requirements", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of work periods |H|" }, - FieldInfo { name: "num_craftsmen", type_name: "usize", description: "Number of craftsmen |C|" }, - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "craftsman_avail", type_name: "Vec>", description: "Availability matrix A(c) for craftsmen (|C| x |H|)" }, - FieldInfo { name: "task_avail", type_name: "Vec>", description: "Availability matrix A(t) for tasks (|T| x |H|)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Required work periods R(c,t) for each craftsman-task pair (|C| x |T|)" }, - ], + fields: TimetableDesignCreateSpec::FIELDS, } } @@ -42,6 +35,92 @@ pub struct TimetableDesign { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TimetableDesignCreateSpec { + /// Number of work periods. + num_periods: usize, + /// Number of craftsmen. + num_craftsmen: usize, + /// Number of tasks. + num_tasks: usize, + /// Craftsman availability matrix. + craftsman_avail: Vec>, + /// Task availability matrix. + task_avail: Vec>, + /// Required work periods for each craftsman-task pair. + requirements: Vec>, +} +impl TryFrom for TimetableDesign { + type Error = String; + fn try_from(spec: TimetableDesignCreateSpec) -> Result { + if spec.craftsman_avail.len() != spec.num_craftsmen { + return Err(format!( + "craftsman_avail has {} rows, expected {}", + spec.craftsman_avail.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .craftsman_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "craftsman_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.task_avail.len() != spec.num_tasks { + return Err(format!( + "task_avail has {} rows, expected {}", + spec.task_avail.len(), + spec.num_tasks + )); + } + if let Some((index, row)) = spec + .task_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "task_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.requirements.len() != spec.num_craftsmen { + return Err(format!( + "requirements has {} rows, expected {}", + spec.requirements.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .requirements + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_tasks) + { + return Err(format!( + "requirements row {index} has {} tasks, expected {}", + row.len(), + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_periods, + spec.num_craftsmen, + spec.num_tasks, + spec.craftsman_avail, + spec.task_avail, + spec.requirements, + )) + } +} + impl TimetableDesign { /// Create a new Timetable Design instance. /// @@ -355,7 +434,7 @@ impl Problem for TimetableDesign { } crate::declare_variants! { - default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)", + default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)" create TimetableDesignCreateSpec, } #[cfg(any(test, feature = "example-db"))] diff --git a/src/models/set/comparative_containment.rs b/src/models/set/comparative_containment.rs index c1ad0629f..94d510c94 100644 --- a/src/models/set/comparative_containment.rs +++ b/src/models/set/comparative_containment.rs @@ -4,7 +4,7 @@ //! whether there exists a subset of the universe whose containment weight //! in the first family is at least its containment weight in the second. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{One, WeightElement}; use num_traits::Zero; @@ -25,13 +25,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["One", "i32", "f64"])], module_path: module_path!(), description: "Compare containment-weight sums for two set families over a shared universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe X" }, - FieldInfo { name: "r_sets", type_name: "Vec>", description: "First set family R over X" }, - FieldInfo { name: "s_sets", type_name: "Vec>", description: "Second set family S over X" }, - FieldInfo { name: "r_weights", type_name: "Vec", description: "Positive weights for sets in R" }, - FieldInfo { name: "s_weights", type_name: "Vec", description: "Positive weights for sets in S" }, - ], + fields: ComparativeContainmentI32CreateSpec::FIELDS, } } @@ -50,6 +44,88 @@ pub struct ComparativeContainment { s_weights: Vec, } +macro_rules! comparative_containment_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Size of the common universe. + universe_size: usize, + /// First set family. + #[create(codec = "semicolon-separated")] + r_sets: Vec>, + /// Second set family. + #[create(codec = "semicolon-separated")] + s_sets: Vec>, + /// Positive weights for the first family; defaults to one. + #[create(codec = "comma-separated")] + r_weights: Option>, + /// Positive weights for the second family; defaults to one. + #[create(codec = "comma-separated")] + s_weights: Option>, + } + + impl TryFrom<$name> for ComparativeContainment<$weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + validate_create_set_family("R", spec.universe_size, &spec.r_sets)?; + validate_create_set_family("S", spec.universe_size, &spec.s_sets)?; + let r_weights = spec + .r_weights + .unwrap_or_else(|| vec![$one; spec.r_sets.len()]); + let s_weights = spec + .s_weights + .unwrap_or_else(|| vec![$one; spec.s_sets.len()]); + validate_create_weights("R", spec.r_sets.len(), &r_weights)?; + validate_create_weights("S", spec.s_sets.len(), &s_weights)?; + Ok(ComparativeContainment { + universe_size: spec.universe_size, + r_sets: spec.r_sets, + s_sets: spec.s_sets, + r_weights, + s_weights, + }) + } + } + }; +} + +fn validate_create_set_family( + label: &str, + universe_size: usize, + sets: &[Vec], +) -> Result<(), String> { + for (set_index, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe_size { + return Err(format!("{label} set {set_index} contains element {element} outside universe of size {universe_size}")); + } + } + } + Ok(()) +} + +fn validate_create_weights( + label: &str, + count: usize, + weights: &[W], +) -> Result<(), String> { + if weights.len() != count { + return Err(format!("number of {label} sets and weights must match")); + } + for (index, weight) in weights.iter().enumerate() { + if weight.to_sum().partial_cmp(&W::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err(format!( + "{label} weight at index {index} must be finite and positive" + )); + } + } + Ok(()) +} + +comparative_containment_create_spec!(ComparativeContainmentI32CreateSpec, i32, 1_i32); +comparative_containment_create_spec!(ComparativeContainmentF64CreateSpec, f64, 1.0_f64); +comparative_containment_create_spec!(ComparativeContainmentOneCreateSpec, One, One); + impl ComparativeContainment { /// Create a new instance with unit weights. pub fn new(universe_size: usize, r_sets: Vec>, s_sets: Vec>) -> Self @@ -200,9 +276,9 @@ where } crate::declare_variants! { - ComparativeContainment => "2^universe_size", - default ComparativeContainment => "2^universe_size", - ComparativeContainment => "2^universe_size", + ComparativeContainment => "2^universe_size" create ComparativeContainmentOneCreateSpec, + default ComparativeContainment => "2^universe_size" create ComparativeContainmentI32CreateSpec, + ComparativeContainment => "2^universe_size" create ComparativeContainmentF64CreateSpec, } fn validate_set_family(label: &str, universe_size: usize, sets: &[Vec]) { diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index c65dc0c67..a4aab2288 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -4,7 +4,7 @@ //! subsets of X, determine if C contains an exact cover -- a subcollection of //! q disjoint triples covering every element exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine if a collection of 3-element subsets contains an exact cover", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of universe X (must be divisible by 3)" }, - FieldInfo { name: "subsets", type_name: "Vec<[usize; 3]>", description: "Collection C of 3-element subsets of X" }, - ], + fields: ExactCoverBy3SetsCreateSpec::FIELDS, } } @@ -61,6 +58,40 @@ pub struct ExactCoverBy3Sets { subsets: Vec<[usize; 3]>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ExactCoverBy3SetsCreateSpec { + universe_size: usize, + #[create(codec = "semicolon-separated")] + subsets: Vec<[usize; 3]>, +} + +impl TryFrom for ExactCoverBy3Sets { + type Error = String; + fn try_from(mut spec: ExactCoverBy3SetsCreateSpec) -> Result { + if !spec.universe_size.is_multiple_of(3) { + return Err("universe_size must be divisible by 3".into()); + } + for (index, subset) in spec.subsets.iter_mut().enumerate() { + if subset[0] == subset[1] || subset[0] == subset[2] || subset[1] == subset[2] { + return Err(format!("subset {index} contains duplicate elements")); + } + if let Some(&element) = subset + .iter() + .find(|&&element| element >= spec.universe_size) + { + return Err(format!( + "subset {index} contains out-of-range element {element}" + )); + } + subset.sort(); + } + Ok(Self { + universe_size: spec.universe_size, + subsets: spec.subsets, + }) + } +} + impl ExactCoverBy3Sets { /// Create a new X3C problem. /// @@ -207,7 +238,7 @@ impl Problem for ExactCoverBy3Sets { } crate::declare_variants! { - default ExactCoverBy3Sets => "2^universe_size", + default ExactCoverBy3Sets => "2^universe_size" create ExactCoverBy3SetsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index fb199d5a2..6dede9a31 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -3,7 +3,7 @@ //! The Set Packing problem asks for a maximum weight collection of //! pairwise disjoint sets. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; use num_traits::Zero; @@ -18,10 +18,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "One", &["One", "i32", "f64"])], module_path: module_path!(), description: "Find maximum weight collection of disjoint sets", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of sets over a universe" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MaximumSetPackingCreateSpec::::FIELDS, } } @@ -61,6 +58,29 @@ pub struct MaximumSetPacking { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumSetPackingCreateSpec { + /// Collection of sets over a universe. + subsets: Vec>, + /// Weight for each set. + weights: Vec, +} + +impl TryFrom> for MaximumSetPacking { + type Error = String; + + fn try_from(spec: MaximumSetPackingCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + Ok(Self::with_weights(spec.subsets, spec.weights)) + } +} + impl MaximumSetPacking { /// Create a new Set Packing problem with unit weights. pub fn new(sets: Vec>) -> Self @@ -166,9 +186,9 @@ where } crate::declare_variants! { - default MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", + default MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, } /// Check if a selection forms a valid set packing (pairwise disjoint). diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index e7b0d47d2..17fdb7b97 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -3,7 +3,7 @@ //! The Minimum Hitting Set problem asks for a minimum-size subset of universe //! elements that intersects every set in a collection. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a minimum-size subset of universe elements that hits every set", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U that must each be hit" }, - ], + fields: MinimumHittingSetCreateSpec::FIELDS, } } @@ -40,6 +37,30 @@ pub struct MinimumHittingSet { sets: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumHittingSetCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U that must each be hit. + subsets: Vec>, +} + +impl TryFrom for MinimumHittingSet { + type Error = String; + + fn try_from(spec: MinimumHittingSetCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets)) + } +} + impl MinimumHittingSet { /// Create a new Minimum Hitting Set instance. /// @@ -144,7 +165,7 @@ impl Problem for MinimumHittingSet { } crate::declare_variants! { - default MinimumHittingSet => "2^universe_size", + default MinimumHittingSet => "2^universe_size" create MinimumHittingSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 4387375a5..fb28fd1a5 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -3,7 +3,7 @@ //! The Set Covering problem asks for a minimum weight collection of sets //! that covers all elements in the universe. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; use num_traits::Zero; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], module_path: module_path!(), description: "Find minimum weight collection covering the universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MinimumSetCoveringCreateSpec::FIELDS, } } @@ -68,6 +64,43 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSetCoveringCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U. + subsets: Vec>, + /// Weight for each subset. + weights: Vec, +} + +impl TryFrom for MinimumSetCovering { + type Error = String; + + fn try_from(spec: MinimumSetCoveringCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::with_weights( + spec.universe_size, + spec.subsets, + spec.weights, + )) + } +} + impl MinimumSetCovering { /// Create a new Set Covering problem with unit weights. pub fn new(universe_size: usize, sets: Vec>) -> Self @@ -171,7 +204,7 @@ where } crate::declare_variants! { - default MinimumSetCovering => "2^num_sets", + default MinimumSetCovering => "2^num_sets" create MinimumSetCoveringCreateSpec, } /// Check if a selection of sets forms a valid set cover. diff --git a/src/models/set/prime_attribute_name.rs b/src/models/set/prime_attribute_name.rs index 96a9741e1..d956424da 100644 --- a/src/models/set/prime_attribute_name.rs +++ b/src/models/set/prime_attribute_name.rs @@ -3,7 +3,7 @@ //! Given a set of attributes A, a collection of functional dependencies F on A, //! and a query attribute x, determine if x belongs to any candidate key of . -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,11 +15,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine if an attribute belongs to any candidate key under functional dependencies", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Number of attributes" }, - FieldInfo { name: "dependencies", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs, rhs) pairs" }, - FieldInfo { name: "query_attribute", type_name: "usize", description: "The query attribute index" }, - ], + fields: PrimeAttributeNameCreateSpec::FIELDS, } } @@ -70,6 +66,51 @@ pub struct PrimeAttributeName { query_attribute: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrimeAttributeNameCreateSpec { + /// Number of attributes. + universe_size: usize, + /// Functional dependencies (lhs, rhs) pairs. + dependencies: Vec<(Vec, Vec)>, + /// The query attribute index. + query_attribute: usize, +} + +impl TryFrom for PrimeAttributeName { + type Error = String; + + fn try_from(spec: PrimeAttributeNameCreateSpec) -> Result { + if spec.query_attribute >= spec.universe_size { + return Err(format!( + "query_attribute {} is outside universe of size {}", + spec.query_attribute, spec.universe_size + )); + } + for (dependency_index, (lhs, rhs)) in spec.dependencies.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "dependencies[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.universe_size) + { + return Err(format!( + "dependencies[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new( + spec.universe_size, + spec.dependencies, + spec.query_attribute, + )) + } +} + impl PrimeAttributeName { /// Create a new Prime Attribute Name problem. /// @@ -205,7 +246,7 @@ impl Problem for PrimeAttributeName { } crate::declare_variants! { - default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes", + default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes" create PrimeAttributeNameCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/set_basis.rs b/src/models/set/set_basis.rs index e1b9f92bf..b8fc22da4 100644 --- a/src/models/set/set_basis.rs +++ b/src/models/set/set_basis.rs @@ -4,7 +4,7 @@ //! determine whether there exist `k` basis sets such that every target set //! can be reconstructed as a union of some subcollection of the basis. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether a collection of sets admits a basis of size k under union", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the ground set S" }, - FieldInfo { name: "collection", type_name: "Vec>", description: "Collection C of target subsets of S" }, - FieldInfo { name: "k", type_name: "usize", description: "Required number of basis sets" }, - ], + fields: SetBasisCreateSpec::FIELDS, } } @@ -40,6 +36,32 @@ pub struct SetBasis { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SetBasisCreateSpec { + /// Size of the ground set S. + universe_size: usize, + /// Collection C of target subsets of S. + subsets: Vec>, + /// Required number of basis sets. + k: usize, +} + +impl TryFrom for SetBasis { + type Error = String; + + fn try_from(spec: SetBasisCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets, spec.k)) + } +} + impl SetBasis { /// Create a new Set Basis instance. /// @@ -171,7 +193,7 @@ impl Problem for SetBasis { } crate::declare_variants! { - default SetBasis => "2^(basis_size * universe_size)", + default SetBasis => "2^(basis_size * universe_size)" create SetBasisCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/registry/info.rs b/src/registry/info.rs index 919670a8e..d39ca69c7 100644 --- a/src/registry/info.rs +++ b/src/registry/info.rs @@ -125,7 +125,7 @@ pub struct ProblemInfo { pub canonical_reduction_from: Option<&'static str>, /// Wikipedia or reference URL. pub reference_url: Option<&'static str>, - /// Struct field descriptions for schema export. + /// Construction input descriptions for schema export. pub fields: &'static [FieldInfo], } @@ -181,7 +181,7 @@ impl ProblemInfo { self } - /// Builder method to set struct field descriptions. + /// Builder method to set construction input descriptions. pub const fn with_fields(mut self, fields: &'static [FieldInfo]) -> Self { self.fields = fields; self @@ -206,10 +206,10 @@ impl fmt::Display for ProblemInfo { } } -/// Description of a struct field for JSON schema export. +/// Description of a problem construction input for schema export. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldInfo { - /// Field name as it appears in the Rust struct. + /// Input name supplied when constructing the problem. pub name: &'static str, /// Type name (e.g., `Vec`, `UnGraph<(), ()>`). pub type_name: &'static str, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 99243a5fc..5e91ffa07 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -60,10 +60,27 @@ pub use schema::{ ProblemSizeFieldEntry, VariantDimension, }; pub use variant::{ - find_variant_by_alias, find_variant_entry, validate_variant_aliases, variant_entries, - VariantEntry, + find_variant_by_alias, find_variant_entry, validate_create_inputs, + validate_direct_create_inputs, validate_variant_aliases, variant_entries, ConstructProblemFn, + ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, VariantEntry, }; +/// Construct a problem from normalized construction inputs using the exact +/// registered problem name and variant. +pub fn construct_dyn( + name: &str, + variant: &BTreeMap, + data: serde_json::Value, +) -> Result, ConstructionError> { + let entry = find_variant_entry(name, variant).ok_or_else(|| { + ConstructionError::UnregisteredVariant { + name: name.to_string(), + variant: variant.clone(), + } + })?; + (entry.construct_fn)(data) +} + use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 5337873c2..20d86fbb5 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -17,7 +17,7 @@ pub struct ProblemType { pub dimensions: &'static [VariantDimension], /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], } diff --git a/src/registry/schema.rs b/src/registry/schema.rs index 3fd9dcecd..0cf5ce15d 100644 --- a/src/registry/schema.rs +++ b/src/registry/schema.rs @@ -46,7 +46,7 @@ pub struct ProblemSchemaEntry { pub module_path: &'static str, /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], } @@ -72,7 +72,7 @@ pub struct ProblemSchemaJson { pub name: String, /// Problem description. pub description: String, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: Vec, } diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 3e64f9140..ef5299e5f 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -4,6 +4,172 @@ use std::any::Any; use std::collections::BTreeMap; use crate::registry::dyn_problem::{DynProblem, SolveValueFn, SolveWitnessFn}; +use crate::registry::FieldInfo; + +/// Reusable syntax used to transport one construction input. +/// +/// `Auto` asks a frontend to choose the codec from `type_name`. The explicit +/// variants are for Rust types whose compact external syntax is ambiguous. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CreateInputCodec { + /// Infer the transport syntax from the Rust value type. + #[default] + Auto, + /// A single scalar value. + Scalar, + /// A JSON value. + Json, + /// Comma-separated values. + CommaSeparated, + /// Semicolon-separated rows or groups. + SemicolonSeparated, + /// Undirected edges such as `0-1,1-2`. + EdgeList, + /// Directed arcs such as `0>1,1>2`. + ArcList, + /// Bipartite-local edges such as `0-0,0-1`. + BipartiteEdgeList, + /// Equality-linked index pairs such as `2=5;4=3`. + EqualityPairList, + /// Functional dependencies such as `0,1:2;2:3,4`. + FunctionalDependencyList, + /// Semicolon-separated character strings sharing one inferred alphabet. + CharacterRows, +} + +/// A user-facing input accepted when constructing a problem instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CreateInputInfo { + /// Input name in snake_case. Frontends may render it in their native style. + pub name: &'static str, + /// Concrete Rust value type accepted by the construction spec. + pub type_name: &'static str, + /// Human-readable input description. + pub description: &'static str, + /// Whether the input must be present. + pub required: bool, + /// Reusable transport syntax for this input. + pub codec: CreateInputCodec, +} + +impl CreateInputInfo { + /// Promote catalog field metadata into a required construction input. + pub const fn from_field(field: FieldInfo) -> Self { + Self { + name: field.name, + type_name: field.type_name, + description: field.description, + required: true, + codec: CreateInputCodec::Auto, + } + } +} + +/// Static construction-input metadata generated from a typed create spec. +pub trait CreateSpec { + /// Construction-facing field metadata used by the problem catalog. + const FIELDS: &'static [FieldInfo]; + /// Inputs accepted by this construction spec. + const INPUTS: &'static [CreateInputInfo]; + + /// Deserialize normalized construction inputs into the typed specification. + fn deserialize_inputs(data: serde_json::Value) -> Result + where + Self: Sized + serde::de::DeserializeOwned, + { + serde_json::from_value(data) + } +} + +/// Failure while validating or applying a model construction contract. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConstructionError { + /// No concrete variant matches the requested problem reference. + #[error("no registered variant for `{name}` with variant {variant:?}")] + UnregisteredVariant { + /// Canonical problem name. + name: String, + /// Exact requested variant. + variant: BTreeMap, + }, + /// Construction values must be supplied as a named JSON object. + #[error("construction inputs must be a JSON object")] + ExpectedObject, + /// A construction contract declared the same input more than once. + #[error("construction input `{0}` is declared more than once")] + DuplicateInput(String), + /// The caller supplied values outside the declared construction contract. + #[error("unknown construction input(s): {}", .0.join(", "))] + UnknownInputs(Vec), + /// The caller omitted required construction values. + #[error("missing required construction input(s): {}", .0.join(", "))] + MissingInputs(Vec), + /// Normalized values could not be deserialized into the direct model or create spec. + #[error("invalid construction input: {0}")] + InvalidInput(String), + /// A typed create spec failed to convert into the problem model. + #[error("problem construction failed: {0}")] + Conversion(String), +} + +/// Type-erased problem constructor used by dynamic frontends. +pub type ConstructProblemFn = + fn(serde_json::Value) -> Result, ConstructionError>; + +/// Validate normalized values against a typed construction contract. +pub fn validate_create_inputs( + inputs: &[CreateInputInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract( + inputs.iter().map(|input| (input.name, input.required)), + data, + ) +} + +/// Validate the direct-construction path backed by catalog field metadata. +/// +/// Direct models have no separate create DTO, so every catalog field is a +/// required construction input. +pub fn validate_direct_create_inputs( + fields: &[FieldInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract(fields.iter().map(|field| (field.name, true)), data) +} + +fn validate_input_contract<'a>( + inputs: impl IntoIterator, + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + let object = data.as_object().ok_or(ConstructionError::ExpectedObject)?; + let mut declared = BTreeMap::new(); + for (name, required) in inputs { + if declared.insert(name, required).is_some() { + return Err(ConstructionError::DuplicateInput(name.to_string())); + } + } + + let unknown = object + .keys() + .filter(|name| !declared.contains_key(name.as_str())) + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(ConstructionError::UnknownInputs(unknown)); + } + + let missing = declared + .into_iter() + .filter(|(name, required)| *required && !object.contains_key(*name)) + .map(|(name, _)| name.to_string()) + .collect::>(); + if !missing.is_empty() { + return Err(ConstructionError::MissingInputs(missing)); + } + + Ok(()) +} /// A registered problem variant entry. /// @@ -28,6 +194,11 @@ pub struct VariantEntry { /// specific reduction-graph node, not just to a canonical problem name. The CLI /// resolver tries variant-level aliases first and falls back to problem-level. pub aliases: &'static [&'static str], + /// Custom construction inputs. `None` means the catalog schema fields are + /// also the construction inputs through the direct path. + pub create_inputs: Option<&'static [CreateInputInfo]>, + /// Construct a validated concrete problem from normalized construction data. + pub construct_fn: ConstructProblemFn, /// Factory: deserialize JSON into a boxed dynamic problem. pub factory: fn(serde_json::Value) -> Result, serde_json::Error>, /// Serialize: downcast `&dyn Any` and serialize to JSON. diff --git a/src/unit_tests/models/algebraic/closest_vector_problem.rs b/src/unit_tests/models/algebraic/closest_vector_problem.rs index ff5e41dc4..f776b7ca4 100644 --- a/src/unit_tests/models/algebraic/closest_vector_problem.rs +++ b/src/unit_tests/models/algebraic/closest_vector_problem.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_expands_shared_default_bounds() { + let problem = ClosestVectorProblem::::try_from(ClosestVectorProblemI32CreateSpec { + basis: vec![vec![1, 0], vec![0, 1]], + target: vec![0.5, 0.5], + bounds: None, + }) + .unwrap(); + assert_eq!(problem.bounds(), &[VarBounds::bounded(-10, 10); 2]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs index 7d66b6459..73b507f77 100644 --- a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs +++ b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs @@ -2,6 +2,20 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_consecutive_block_create_spec_uses_bound_k_input() { + assert_eq!( + ConsecutiveBlockMinimizationCreateSpec::FIELDS[1].name, + "bound_k" + ); + let problem = ConsecutiveBlockMinimization::try_from(ConsecutiveBlockMinimizationCreateSpec { + matrix: vec![vec![true, false]], + bound_k: 1, + }) + .unwrap(); + assert_eq!(problem.bound(), 1); +} + #[test] fn test_consecutive_block_minimization_basic() { let problem = ConsecutiveBlockMinimization::new( diff --git a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs index 16fc52b93..58b77c42d 100644 --- a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_rejects_negative_bound() { + assert_eq!( + ConsecutiveOnesMatrixAugmentationCreateSpec::FIELDS[1].name, + "bound" + ); + assert!(ConsecutiveOnesMatrixAugmentation::try_from( + ConsecutiveOnesMatrixAugmentationCreateSpec { + matrix: vec![vec![true]], + bound: -1 + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/algebraic/feasible_basis_extension.rs b/src/unit_tests/models/algebraic/feasible_basis_extension.rs index dc7136e51..dea8a49ab 100644 --- a/src/unit_tests/models/algebraic/feasible_basis_extension.rs +++ b/src/unit_tests/models/algebraic/feasible_basis_extension.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_validates_matrix_shape() { + let problem = FeasibleBasisExtension::try_from(FeasibleBasisExtensionCreateSpec { + matrix: vec![vec![1, 0]], + rhs: vec![1], + required_columns: vec![], + }) + .unwrap(); + assert_eq!(problem.num_columns(), 2); + assert!( + FeasibleBasisExtension::try_from(FeasibleBasisExtensionCreateSpec { + matrix: vec![vec![1], vec![1]], + rhs: vec![1, 1], + required_columns: vec![] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs index 573353399..98ecf3ead 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_maps_rhs_to_target() { + let problem = MinimumWeightDecoding::try_from(MinimumWeightDecodingCreateSpec { + matrix: vec![vec![true, false]], + target: vec![true], + }) + .unwrap(); + assert_eq!(problem.target(), &[true]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs index 4c6b2b30d..8c6b30d61 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_rhs_length_mismatch() { + assert!( + MinimumWeightSolutionToLinearEquations::try_from(MinimumWeightSolutionCreateSpec { + matrix: vec![vec![1, 2]], + rhs: vec![] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 83ebae164..7332b5034 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -118,3 +118,15 @@ fn test_qubo_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(Problem::evaluate(&problem, &best), Min(Some(-2.0))); } + +#[test] +fn test_qubo_create_spec_derives_num_vars() { + let problem = QUBO::try_from(QuboCreateSpec { + matrix: vec![vec![1.0, 2.0], vec![0.0, 3.0]], + }) + .unwrap(); + + assert_eq!(problem.num_vars(), 2); + assert_eq!(QuboCreateSpec::FIELDS[0].name, "matrix"); + assert_eq!(QuboCreateSpec::FIELDS.len(), 1); +} diff --git a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs index 2d3b48630..e9c42055f 100644 --- a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs +++ b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_zero_bound() { + assert_eq!(SparseMatrixCompressionCreateSpec::FIELDS[1].name, "bound_k"); + let result = SparseMatrixCompression::try_from(SparseMatrixCompressionCreateSpec { + matrix: vec![vec![true]], + bound_k: 0, + }); + assert!(result.is_err()); +} use crate::registry::VariantEntry; use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index e78a2d19b..78c604e38 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -76,6 +76,50 @@ fn test_decision_serialization() { assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); } +#[test] +fn construction_contract_decision_uses_flat_inner_fields() { + let inner = triangle_mvc(); + let mut flat = serde_json::to_value(&inner) + .unwrap() + .as_object() + .unwrap() + .clone(); + flat.insert("bound".to_string(), serde_json::json!(2)); + let variant = crate::export::variant_to_map( + > as Problem>::variant(), + ); + + let constructed = crate::registry::construct_dyn( + "DecisionMinimumVertexCover", + &variant, + serde_json::Value::Object(flat), + ) + .unwrap(); + let canonical = constructed.serialize_json(); + + assert!(canonical.get("inner").is_some()); + assert_eq!(canonical["bound"], serde_json::json!(2)); + assert_eq!(canonical["inner"]["weights"], serde_json::json!([1, 1, 1])); +} + +#[test] +fn construction_contract_decision_rejects_nested_persisted_shape() { + let variant = crate::export::variant_to_map( + > as Problem>::variant(), + ); + let error = crate::registry::construct_dyn( + "DecisionMinimumVertexCover", + &variant, + serde_json::json!({"inner": triangle_mvc(), "bound": 2}), + ) + .err() + .expect("nested persisted shape must not be accepted for construction"); + + assert!(error + .to_string() + .contains("unknown construction input(s): inner")); +} + #[test] fn test_decision_reduce_to_aggregate() { use crate::rules::{AggregateReductionResult, ReduceToAggregate}; diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index 70e1d7df8..bebd5c874 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -215,3 +215,31 @@ fn test_acyclic_partition_declares_problem_size_fields() { .collect(); assert_eq!(fields, HashSet::from(["num_vertices", "num_arcs"])); } +#[test] +fn create_spec_maps_weight_inputs_to_canonical_fields() { + let problem = AcyclicPartition::try_from(AcyclicPartitionCreateSpec { + arcs: vec![(0, 1)], + num_vertices: Some(3), + weights: None, + arc_weights: Some(vec![2]), + weight_bound: 3, + cost_bound: 2, + }) + .unwrap(); + assert_eq!(problem.vertex_weights(), &[1, 1, 1]); + assert_eq!(problem.arc_costs(), &[2]); + assert_eq!( + AcyclicPartitionCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + [ + "arcs", + "num_vertices", + "weights", + "arc_costs", + "weight_bound", + "cost_bound" + ] + ); +} diff --git a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs index 2faab061f..f9a13a9f7 100644 --- a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,4 +1,27 @@ use super::*; + +#[test] +fn create_spec_builds_bipartite_graph_and_rejects_invalid_edges() { + let problem = + BalancedCompleteBipartiteSubgraph::try_from(BalancedCompleteBipartiteSubgraphCreateSpec { + left: 2, + right: 2, + biedges: vec![(0, 1), (1, 0)], + k: 1, + }) + .unwrap(); + assert_eq!(problem.graph().left_edges(), &[(0, 1), (1, 0)]); + assert_eq!(problem.k(), 1); + assert!(BalancedCompleteBipartiteSubgraph::try_from( + BalancedCompleteBipartiteSubgraphCreateSpec { + left: 1, + right: 1, + biedges: vec![(1, 0)], + k: 1, + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::BipartiteGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/biclique_cover.rs b/src/unit_tests/models/graph/biclique_cover.rs index 6693e4fa2..30a60e1a6 100644 --- a/src/unit_tests/models/graph/biclique_cover.rs +++ b/src/unit_tests/models/graph/biclique_cover.rs @@ -4,6 +4,77 @@ use crate::topology::BipartiteGraph; use crate::traits::Problem; use crate::types::Min; +#[test] +fn test_biclique_cover_create_spec_constructs_graph() { + let problem = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 2, + right: 3, + biedges: vec![(0, 0), (0, 2), (1, 1)], + k: 2, + }) + .unwrap(); + + assert_eq!(problem.left_size(), 2); + assert_eq!(problem.right_size(), 3); + assert_eq!(problem.graph().left_edges(), &[(0, 0), (0, 2), (1, 1)]); + assert_eq!(problem.k(), 2); + + let entry = inventory::iter::() + .find(|entry| entry.name == "BicliqueCover") + .unwrap(); + let inputs = entry.create_inputs.unwrap(); + assert_eq!( + inputs.iter().map(|input| input.name).collect::>(), + vec!["left", "right", "biedges", "k"] + ); + assert_eq!( + inputs[2].codec, + crate::registry::CreateInputCodec::BipartiteEdgeList + ); + + let constructed = (entry.construct_fn)(serde_json::json!({ + "left": 2, + "right": 3, + "biedges": [[0, 0], [0, 2], [1, 1]], + "k": 2 + })) + .unwrap(); + let constructed = constructed + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + constructed.graph().left_edges(), + problem.graph().left_edges() + ); + assert_eq!(constructed.k(), problem.k()); +} + +#[test] +fn test_biclique_cover_create_spec_rejects_out_of_bounds_edges() { + let invalid_left = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 1, + right: 2, + biedges: vec![(1, 0)], + k: 1, + }); + assert_eq!( + invalid_left.unwrap_err(), + "biedges[0] left vertex 1 is out of bounds for left partition size 1" + ); + + let invalid_right = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 2, + right: 1, + biedges: vec![(0, 1)], + k: 1, + }); + assert_eq!( + invalid_right.unwrap_err(), + "biedges[0] right vertex 1 is out of bounds for right partition size 1" + ); +} + #[test] fn test_biclique_cover_creation() { let graph = BipartiteGraph::new(2, 2, vec![(0, 0), (0, 1), (1, 0)]); diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index db4f33fc7..a821ead2d 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -1,4 +1,16 @@ use super::*; +#[test] +fn create_spec_rejects_existing_potential_edge() { + assert!( + BiconnectivityAugmentation::try_from(BiconnectivityAugmentationCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + potential_weights: vec![(0, 1, 2)], + budget: 3 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs index e8f72c0df..4b317807c 100644 --- a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs +++ b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs @@ -120,3 +120,17 @@ fn test_bottleneck_traveling_salesman_paper_example() { assert_eq!(best.len(), 1); assert_eq!(best[0], config); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = BottleneckTravelingSalesman::try_from(BottleneckTravelingSalesmanCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1]); + assert_eq!( + BottleneckTravelingSalesmanCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs index 5e2573001..87a810d30 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -6,6 +6,25 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::sync::atomic::{AtomicUsize, Ordering}; +#[test] +fn create_spec_uses_k_and_max_weight_inputs() { + let names: Vec<_> = BoundedComponentSpanningForestCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect(); + assert_eq!(names, ["graph", "weights", "k", "max_weight"]); + let problem = + BoundedComponentSpanningForest::try_from(BoundedComponentSpanningForestCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1, 2], + k: 1, + max_weight: 3, + }) + .unwrap(); + assert_eq!(problem.max_components(), 1); + assert_eq!(problem.max_weight(), &3); +} + struct CountingAllocator; static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0); diff --git a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs index d4325e603..58f830222 100644 --- a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs @@ -132,3 +132,19 @@ fn test_bounded_diameter_spanning_tree_wrong_weights_length_panics() { let _ = BoundedDiameterSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1], 5, 2); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = BoundedDiameterSpanningTree::try_from(BoundedDiameterSpanningTreeCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + weight_bound: 1, + diameter_bound: 1, + }) + .unwrap(); + assert_eq!(problem.edge_weights(), &[1]); + assert_eq!( + BoundedDiameterSpanningTreeCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/disjoint_connecting_paths.rs b/src/unit_tests/models/graph/disjoint_connecting_paths.rs index 6c613bd07..e4be74a02 100644 --- a/src/unit_tests/models/graph/disjoint_connecting_paths.rs +++ b/src/unit_tests/models/graph/disjoint_connecting_paths.rs @@ -1,4 +1,15 @@ use super::*; +#[test] +fn create_spec_rejects_reused_terminal() { + assert!( + DisjointConnectingPaths::try_from(DisjointConnectingPathsCreateSpec { + graph: vec![(0, 1), (1, 2)], + num_vertices: None, + terminal_pairs: vec![(0, 1), (1, 2)] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/generalized_hex.rs b/src/unit_tests/models/graph/generalized_hex.rs index 7b9799099..57ebded1b 100644 --- a/src/unit_tests/models/graph/generalized_hex.rs +++ b/src/unit_tests/models/graph/generalized_hex.rs @@ -3,6 +3,18 @@ use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; +#[test] +fn create_spec_uses_sink_input() { + assert_eq!(GeneralizedHexCreateSpec::FIELDS[2].name, "sink"); + let problem = GeneralizedHex::try_from(GeneralizedHexCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + source: 0, + sink: 1, + }) + .unwrap(); + assert_eq!(problem.target(), 1); +} + fn issue_example() -> GeneralizedHex { GeneralizedHex::new( SimpleGraph::new( diff --git a/src/unit_tests/models/graph/integral_flow_bundles.rs b/src/unit_tests/models/graph/integral_flow_bundles.rs index 0e95b3c24..d55cb1e60 100644 --- a/src/unit_tests/models/graph/integral_flow_bundles.rs +++ b/src/unit_tests/models/graph/integral_flow_bundles.rs @@ -1,4 +1,19 @@ use super::*; +#[test] +fn create_spec_requires_bundle_coverage() { + assert!( + IntegralFlowBundles::try_from(IntegralFlowBundlesCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + bundles: vec![vec![0]], + bundle_capacities: vec![1], + source: 0, + sink: 2, + requirement: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs index 2ce6a5e7d..4900cce87 100644 --- a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs +++ b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs @@ -1,4 +1,18 @@ use super::*; +#[test] +fn create_spec_defaults_capacities() { + let problem = IntegralFlowHomologousArcs::try_from(IntegralFlowHomologousArcsCreateSpec { + arcs: vec![(0, 1)], + num_vertices: None, + capacities: None, + source: 0, + sink: 1, + requirement: 1, + homologous_pairs: vec![], + }) + .unwrap(); + assert_eq!(problem.capacities(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs index a4afd16af..865160196 100644 --- a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs +++ b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs @@ -1,4 +1,19 @@ use super::*; +#[test] +fn create_spec_rejects_zero_internal_multiplier() { + assert!( + IntegralFlowWithMultipliers::try_from(IntegralFlowWithMultipliersCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + capacities: vec![1, 1], + source: 0, + sink: 2, + multipliers: vec![1, 0, 1], + requirement: 1 + }) + .is_err() + ); +} use crate::registry::declared_size_fields; use crate::solvers::BruteForce; use crate::topology::DirectedGraph; diff --git a/src/unit_tests/models/graph/kclique.rs b/src/unit_tests/models/graph/kclique.rs index 16aca9e99..cd8ae19d1 100644 --- a/src/unit_tests/models/graph/kclique.rs +++ b/src/unit_tests/models/graph/kclique.rs @@ -1,4 +1,13 @@ use super::*; +#[test] +fn create_spec_rejects_k_above_vertex_count() { + assert!(KClique::try_from(KCliqueCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + k: 3 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 2185b0337..f2e9618ed 100644 --- a/src/unit_tests/models/graph/kcoloring.rs +++ b/src/unit_tests/models/graph/kcoloring.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_specs_separate_runtime_and_fixed_color_counts() { + let runtime = KColoring::::try_from(RuntimeKColoringCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + k: 4, + }) + .unwrap(); + assert_eq!(runtime.num_vertices(), 3); + assert_eq!(runtime.num_colors(), 4); + + let fixed = KColoring::::try_from(FixedKColoringCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + }) + .unwrap(); + assert_eq!(fixed.num_colors(), 3); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::variant::{K1, K2, K3, K4}; diff --git a/src/unit_tests/models/graph/kth_best_spanning_tree.rs b/src/unit_tests/models/graph/kth_best_spanning_tree.rs index 1c3464260..8a29441b2 100644 --- a/src/unit_tests/models/graph/kth_best_spanning_tree.rs +++ b/src/unit_tests/models/graph/kth_best_spanning_tree.rs @@ -154,3 +154,19 @@ fn test_kthbestspanningtree_creation_rejects_weight_length_mismatch() { fn test_kthbestspanningtree_creation_rejects_zero_k() { let _ = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 0, 0); } +#[test] +fn create_spec_maps_edge_weights_to_weights() { + let problem = KthBestSpanningTree::try_from(KthBestSpanningTreeCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + k: 1, + bound: 2, + }) + .unwrap(); + assert_eq!(problem.weights(), &[1]); + assert_eq!( + KthBestSpanningTreeCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index 53a19ed4f..9f9b3054c 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_derives_path_slot_bound() { + let problem = LengthBoundedDisjointPaths::try_from(LengthBoundedDisjointPathsCreateSpec { + graph: vec![(0, 1), (1, 3), (0, 2), (2, 3)], + num_vertices: None, + source: 0, + sink: 3, + max_length: 2, + }) + .unwrap(); + assert_eq!(problem.max_paths(), 2); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/longest_circuit.rs b/src/unit_tests/models/graph/longest_circuit.rs index e11c1258e..acd6d871b 100644 --- a/src/unit_tests/models/graph/longest_circuit.rs +++ b/src/unit_tests/models/graph/longest_circuit.rs @@ -116,3 +116,14 @@ fn test_longest_circuit_set_lengths_rejects_non_positive_values() { ); problem.set_lengths(vec![1, -2, 1]); } +#[test] +fn create_spec_maps_edge_weights_to_edge_lengths() { + let problem = LongestCircuit::try_from(LongestCircuitCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: Some(vec![3]), + }) + .unwrap(); + assert_eq!(problem.edge_lengths(), &[3]); + assert_eq!(LongestCircuitCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/longest_path.rs b/src/unit_tests/models/graph/longest_path.rs index 9b61616fe..7e7ff5aa4 100644 --- a/src/unit_tests/models/graph/longest_path.rs +++ b/src/unit_tests/models/graph/longest_path.rs @@ -1,4 +1,15 @@ use super::*; +#[test] +fn create_spec_rejects_nonpositive_lengths() { + assert!(LongestPath::try_from(LongestPathI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_lengths: vec![0], + source_vertex: 0, + target_vertex: 1 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/max_cut.rs b/src/unit_tests/models/graph/max_cut.rs index b75ce5bdf..1f4aef639 100644 --- a/src/unit_tests/models/graph/max_cut.rs +++ b/src/unit_tests/models/graph/max_cut.rs @@ -154,3 +154,21 @@ fn test_maxcut_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best).unwrap(), 5); } +#[test] +fn create_specs_use_edge_weights_for_both_weight_variants() { + let weighted = MaxCut::try_from(MaxCutI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + let unit = MaxCut::try_from(MaxCutOneCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(weighted.edge_weights(), vec![1]); + assert_eq!(unit.edge_weights(), vec![One]); + assert_eq!(MaxCutI32CreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/maximal_is.rs b/src/unit_tests/models/graph/maximal_is.rs index d0f1a1f1c..4e1616328 100644 --- a/src/unit_tests/models/graph/maximal_is.rs +++ b/src/unit_tests/models/graph/maximal_is.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!(MaximalISCreateSpec::FIELDS[1].name, "weights"); + let result = MaximalIS::try_from(MaximalISCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; include!("../../jl_helpers.rs"); diff --git a/src/unit_tests/models/graph/maximum_clique.rs b/src/unit_tests/models/graph/maximum_clique.rs index a91da8e33..c6d2df42a 100644 --- a/src/unit_tests/models/graph/maximum_clique.rs +++ b/src/unit_tests/models/graph/maximum_clique.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!(MaximumCliqueCreateSpec::::FIELDS[1].name, "weights"); + let result = MaximumClique::try_from(MaximumCliqueCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::types::{Max, One}; diff --git a/src/unit_tests/models/graph/maximum_co_k_plex.rs b/src/unit_tests/models/graph/maximum_co_k_plex.rs index 0630c5733..89510fd33 100644 --- a/src/unit_tests/models/graph/maximum_co_k_plex.rs +++ b/src/unit_tests/models/graph/maximum_co_k_plex.rs @@ -6,6 +6,19 @@ use crate::types::{Max, One}; use crate::variant::KN; use crate::Solver; +#[test] +fn create_spec_uses_k_input() { + assert_eq!(MaximumCoKPlexCreateSpec::::FIELDS[2].name, "k"); + let problem = MaximumCoKPlex::try_from(MaximumCoKPlexCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![2, 3], + k: 1, + }) + .unwrap(); + assert_eq!(problem.bound_k(), 1); + assert_eq!(problem.weights(), &[2, 3]); +} + fn c5() -> SimpleGraph { SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]) } diff --git a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs index 9762cfa0c..6d0452996 100644 --- a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MaximumEdgeWeightedKClique::try_from(MaximumEdgeWeightedKCliqueCreateSpec:: { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: None, + k: 2, + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/maximum_independent_set.rs b/src/unit_tests/models/graph/maximum_independent_set.rs index 9053054c9..0b37e0c25 100644 --- a/src/unit_tests/models/graph/maximum_independent_set.rs +++ b/src/unit_tests/models/graph/maximum_independent_set.rs @@ -1,4 +1,14 @@ use super::*; +#[test] +fn create_spec_defaults_simple_weights() { + let problem = MaximumIndependentSet::try_from(MaximumIndependentSetSimpleI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), &[1, 1, 1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/maximum_matching.rs b/src/unit_tests/models/graph/maximum_matching.rs index d01e67dfc..b3932223d 100644 --- a/src/unit_tests/models/graph/maximum_matching.rs +++ b/src/unit_tests/models/graph/maximum_matching.rs @@ -187,3 +187,14 @@ fn test_matching_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best).unwrap(), 2); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = MaximumMatching::try_from(MaximumMatchingCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1]); + assert_eq!(MaximumMatchingCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/min_max_multicenter.rs b/src/unit_tests/models/graph/min_max_multicenter.rs index c50d605d2..f0d8616b5 100644 --- a/src/unit_tests/models/graph/min_max_multicenter.rs +++ b/src/unit_tests/models/graph/min_max_multicenter.rs @@ -202,3 +202,30 @@ fn test_minmaxmulticenter_negative_edge_length() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32, -1], 1); } +#[test] +fn create_specs_map_weight_inputs_for_both_variants() { + let weighted = MinMaxMulticenter::try_from(MinMaxMulticenterI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: None, + edge_weights: Some(vec![2]), + k: 1, + }) + .unwrap(); + let unit = MinMaxMulticenter::try_from(MinMaxMulticenterOneCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: None, + edge_weights: None, + k: 1, + }) + .unwrap(); + assert_eq!(weighted.vertex_weights(), &[1, 1]); + assert_eq!(weighted.edge_lengths(), &[2]); + assert_eq!(unit.vertex_weights(), &[One, One]); + assert_eq!(MinMaxMulticenterI32CreateSpec::FIELDS[2].name, "weights"); + assert_eq!( + MinMaxMulticenterI32CreateSpec::FIELDS[3].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs index 5c74e8626..367c31d80 100644 --- a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MinimumCapacitatedSpanningTree::try_from(MinimumCapacitatedSpanningTreeCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: None, + root: 0, + requirements: vec![0, 1], + capacity: 1, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; /// 5-vertex instance from issue #901. diff --git a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs index fe40f07b3..87d16b852 100644 --- a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MinimumCutIntoBoundedSets::try_from(MinimumCutIntoBoundedSetsCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: None, + source: 0, + sink: 1, + size_bound: 1, + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_dominating_set.rs b/src/unit_tests/models/graph/minimum_dominating_set.rs index b80eaedb5..5336b95b2 100644 --- a/src/unit_tests/models/graph/minimum_dominating_set.rs +++ b/src/unit_tests/models/graph/minimum_dominating_set.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!( + MinimumDominatingSetCreateSpec::::FIELDS[1].name, + "weights" + ); + let result = MinimumDominatingSet::try_from(MinimumDominatingSetCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs index c402935ac..b08f47b4d 100644 --- a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs +++ b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_rejects_cycle() { + assert_eq!(MinimumDummyActivitiesPertCreateSpec::FIELDS[0].name, "arcs"); + assert!( + MinimumDummyActivitiesPert::try_from(MinimumDummyActivitiesPertCreateSpec { + arcs: vec![(0, 1), (1, 0)], + num_vertices: Some(2), + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs index 1ede2865b..5627bfe0f 100644 --- a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_defaults_arc_weights() { + let p = MinimumFeedbackArcSet::try_from(MinimumFeedbackArcSetCreateSpec { + graph: DirectedGraph::new(2, vec![(0, 1)]), + weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs index 00fd26274..c3fa3ff02 100644 --- a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -1,4 +1,14 @@ -use super::is_feedback_vertex_set; +use super::*; + +#[test] +fn create_spec_defaults_vertex_weights() { + let p = MinimumFeedbackVertexSet::try_from(MinimumFeedbackVertexSetCreateSpec { + graph: DirectedGraph::new(2, vec![(0, 1)]), + weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::models::graph::MinimumFeedbackVertexSet; use crate::solvers::BruteForce; use crate::topology::DirectedGraph; diff --git a/src/unit_tests/models/graph/minimum_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index 9cbbe5511..9f6f6a18b 100644 --- a/src/unit_tests/models/graph/minimum_multiway_cut.rs +++ b/src/unit_tests/models/graph/minimum_multiway_cut.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_invalid_terminals() { + assert_eq!(MinimumMultiwayCutCreateSpec::FIELDS[1].name, "terminals"); + let result = MinimumMultiwayCut::try_from(MinimumMultiwayCutCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + terminals: vec![0, 0], + edge_weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs index fdf833111..5c1882af5 100644 --- a/src/unit_tests/models/graph/minimum_sum_multicenter.rs +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -263,3 +263,21 @@ fn test_min_sum_multicenter_serialization() { deserialized.evaluate(&config).unwrap() ); } +#[test] +fn create_spec_maps_weight_inputs_to_canonical_fields() { + let problem = MinimumSumMulticenter::try_from(MinimumSumMulticenterCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: Some(vec![2, 3]), + edge_weights: None, + k: 1, + }) + .unwrap(); + assert_eq!(problem.vertex_weights(), &[2, 3]); + assert_eq!(problem.edge_lengths(), &[1]); + assert_eq!(MinimumSumMulticenterCreateSpec::FIELDS[2].name, "weights"); + assert_eq!( + MinimumSumMulticenterCreateSpec::FIELDS[3].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/minimum_vertex_cover.rs b/src/unit_tests/models/graph/minimum_vertex_cover.rs index 2fb7b22a1..6b4dd506e 100644 --- a/src/unit_tests/models/graph/minimum_vertex_cover.rs +++ b/src/unit_tests/models/graph/minimum_vertex_cover.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!( + MinimumVertexCoverCreateSpec::::FIELDS[1].name, + "weights" + ); + let result = MinimumVertexCover::try_from(MinimumVertexCoverCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: Some(vec![1]), + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs index a0ca382b3..721220dc9 100644 --- a/src/unit_tests/models/graph/mixed_chinese_postman.rs +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_infers_graph_and_default_weights() { + let problem = MixedChinesePostman::::try_from(MixedChinesePostmanI32CreateSpec { + graph: vec![(0, 1)], + arcs: vec![(1, 0)], + num_vertices: None, + arc_weights: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 2); + assert_eq!(problem.arc_weights(), &[1]); + assert_eq!(problem.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::MixedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/multiple_choice_branching.rs b/src/unit_tests/models/graph/multiple_choice_branching.rs index 80ceb6750..aa05379e8 100644 --- a/src/unit_tests/models/graph/multiple_choice_branching.rs +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_invalid_partition() { + assert_eq!( + MultipleChoiceBranchingCreateSpec::FIELDS[3].name, + "partition" + ); + let result = MultipleChoiceBranching::try_from(MultipleChoiceBranchingCreateSpec { + arcs: vec![(0, 1)], + num_vertices: Some(2), + weights: vec![1], + partition: vec![], + threshold: 1, + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs index 17f0cad62..abfc1683d 100644 --- a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs +++ b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_preserves_isolated_vertices() { + let problem = MultipleCopyFileAllocation::try_from(MultipleCopyFileAllocationCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + usage: vec![1, 1, 1], + storage: vec![2, 2, 2], + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 3); +} use crate::solvers::{BruteForce, Solver}; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/partial_feedback_edge_set.rs b/src/unit_tests/models/graph/partial_feedback_edge_set.rs index 2f6974355..4fef7554c 100644 --- a/src/unit_tests/models/graph/partial_feedback_edge_set.rs +++ b/src/unit_tests/models/graph/partial_feedback_edge_set.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_constructs_model() { + assert_eq!( + PartialFeedbackEdgeSetCreateSpec::FIELDS[2].name, + "max_cycle_length" + ); + let problem = PartialFeedbackEdgeSet::try_from(PartialFeedbackEdgeSetCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + budget: 1, + max_cycle_length: 3, + }) + .unwrap(); + assert_eq!(problem.budget(), 1); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/path_constrained_network_flow.rs b/src/unit_tests/models/graph/path_constrained_network_flow.rs index 751cde06e..9aef88f72 100644 --- a/src/unit_tests/models/graph/path_constrained_network_flow.rs +++ b/src/unit_tests/models/graph/path_constrained_network_flow.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_defaults_capacities_and_validates_paths() { + let problem = PathConstrainedNetworkFlow::try_from(PathConstrainedNetworkFlowCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + capacities: None, + source: 0, + sink: 2, + paths: vec![vec![0, 1]], + requirement: 1, + }) + .unwrap(); + assert_eq!(problem.capacities(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs index d7efc585c..12e626795 100644 --- a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs +++ b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs @@ -171,3 +171,32 @@ fn test_prize_collecting_steiner_forest_rejects_edge_costs_length_mismatch() { 2, ); } +#[test] +fn create_specs_default_prizes_and_costs_to_one() { + let weighted = + PrizeCollectingSteinerForest::try_from(PrizeCollectingSteinerForestI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + vertex_prizes: None, + edge_costs: None, + beta: 2, + omega: 3, + }) + .unwrap(); + let floating = + PrizeCollectingSteinerForest::try_from(PrizeCollectingSteinerForestF64CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + vertex_prizes: None, + edge_costs: None, + beta: 2.0, + omega: 3.0, + }) + .unwrap(); + assert_eq!(weighted.vertex_prizes(), &[1, 1, 1]); + assert_eq!(weighted.edge_costs(), &[1]); + assert_eq!(floating.vertex_prizes(), &[1.0, 1.0]); + assert_eq!(floating.edge_costs(), &[1.0]); + assert!(!PrizeCollectingSteinerForestI32CreateSpec::INPUTS[2].required); + assert!(!PrizeCollectingSteinerForestI32CreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs index 341fbdef5..ab598c751 100644 --- a/src/unit_tests/models/graph/rural_postman.rs +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -201,3 +201,15 @@ fn test_rural_postman_solver_aggregate() { let value = solver.solve(&problem); assert_eq!(value, Min(Some(4))); } +#[test] +fn create_spec_maps_edge_weights_to_edge_lengths() { + let problem = RuralPostman::try_from(RuralPostmanCreateSpec { + graph: vec![(0, 1), (1, 2)], + num_vertices: None, + edge_weights: Some(vec![2, 3]), + required_edges: vec![1], + }) + .unwrap(); + assert_eq!(problem.edge_lengths(), &[2, 3]); + assert_eq!(RuralPostmanCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs index 1f845e1bc..c9341c6ff 100644 --- a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs +++ b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_nonpositive_edge_values() { + assert_eq!( + ShortestWeightConstrainedPathCreateSpec::FIELDS[1].name, + "edge_lengths" + ); + let result = ShortestWeightConstrainedPath::try_from(ShortestWeightConstrainedPathCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_lengths: vec![0], + edge_weights: vec![1], + source_vertex: 0, + target_vertex: 1, + weight_bound: 1, + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/spin_glass.rs b/src/unit_tests/models/graph/spin_glass.rs index 82633c899..004a45dad 100644 --- a/src/unit_tests/models/graph/spin_glass.rs +++ b/src/unit_tests/models/graph/spin_glass.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_couplings_and_fields() { + let problem = SpinGlass::::try_from(SpinGlassI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + couplings: None, + fields: None, + }) + .unwrap(); + assert_eq!(problem.couplings(), &[1]); + assert_eq!(problem.fields(), &[0, 0, 0]); +} use crate::solvers::BruteForce; use crate::traits::Problem; include!("../../jl_helpers.rs"); diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 00cd8d505..c51dec498 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_duplicate_terminals() { + assert_eq!(SteinerTreeCreateSpec::::FIELDS[2].name, "terminals"); + let result = SteinerTree::try_from(SteinerTreeCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: vec![1], + terminals: vec![0, 0], + }); + assert!(result.is_err()); +} use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; /// Issue #122 example: 5 vertices, 7 edges, terminals {0, 2, 4}. diff --git a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs index cf09155cc..34f98928a 100644 --- a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs +++ b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = SteinerTreeInGraphs::try_from(SteinerTreeInGraphsCreateSpec:: { + graph: SimpleGraph::new(2, vec![(0, 1)]), + terminals: vec![0, 1], + edge_weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/traveling_salesman.rs b/src/unit_tests/models/graph/traveling_salesman.rs index 1dc55c774..a16417be8 100644 --- a/src/unit_tests/models/graph/traveling_salesman.rs +++ b/src/unit_tests/models/graph/traveling_salesman.rs @@ -255,3 +255,14 @@ fn test_tsp_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best), Min(Some(6))); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = TravelingSalesman::try_from(TravelingSalesmanCreateSpec { + graph: vec![(0, 1), (1, 2), (2, 0)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1, 1, 1]); + assert_eq!(TravelingSalesmanCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs index ab54df324..a5e68dad2 100644 --- a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs +++ b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_rejects_lower_bound_above_capacity() { + assert_eq!( + UndirectedFlowLowerBoundsCreateSpec::FIELDS[2].name, + "lower_bounds" + ); + assert!( + UndirectedFlowLowerBounds::try_from(UndirectedFlowLowerBoundsCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + capacities: vec![1], + lower_bounds: vec![2], + source: 0, + sink: 1, + requirement: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs index c34f21bc9..3ff4dcd0a 100644 --- a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_validates_capacity_shape() { + let problem = UndirectedTwoCommodityIntegralFlow::try_from( + UndirectedTwoCommodityIntegralFlowCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + capacities: vec![1], + source_1: 0, + sink_1: 1, + source_2: 1, + sink_2: 0, + requirement_1: 1, + requirement_2: 1, + }, + ) + .unwrap(); + assert_eq!(problem.capacities(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs index d036f415e..325e67dc0 100644 --- a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs @@ -15,6 +15,22 @@ fn canonical_problem() -> BoyceCoddNormalFormViolation { ) } +#[test] +fn test_bcnf_create_spec_uses_construction_names() { + let names: Vec<_> = BoyceCoddNormalFormViolationCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect(); + assert_eq!(names, ["n", "subsets", "target"]); + let problem = BoyceCoddNormalFormViolation::try_from(BoyceCoddNormalFormViolationCreateSpec { + n: 3, + subsets: vec![(vec![0], vec![1])], + target: vec![0, 1, 2], + }) + .unwrap(); + assert_eq!(problem.num_attributes(), 3); +} + #[test] fn test_bcnf_creation() { let problem = canonical_problem(); diff --git a/src/unit_tests/models/misc/capacity_assignment.rs b/src/unit_tests/models/misc/capacity_assignment.rs index ffe135e3d..548fca7e0 100644 --- a/src/unit_tests/models/misc/capacity_assignment.rs +++ b/src/unit_tests/models/misc/capacity_assignment.rs @@ -1,4 +1,16 @@ +use super::CapacityAssignmentCreateSpec; use crate::models::misc::CapacityAssignment; + +#[test] +fn create_spec_validates_monotonicity() { + assert!(CapacityAssignment::try_from(CapacityAssignmentCreateSpec { + capacities: vec![1, 2], + cost: vec![vec![2, 1]], + delay: vec![vec![2, 1]], + delay_budget: 3 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/conjunctive_boolean_query.rs b/src/unit_tests/models/misc/conjunctive_boolean_query.rs index 3ca9e701e..95b558564 100644 --- a/src/unit_tests/models/misc/conjunctive_boolean_query.rs +++ b/src/unit_tests/models/misc/conjunctive_boolean_query.rs @@ -135,3 +135,36 @@ fn test_conjunctivebooleanquery_paper_example() { assert_eq!(all.len(), 1); assert_eq!(all[0], vec![0, 1]); } + +#[test] +fn test_conjunctivebooleanquery_create_spec_derives_variables() { + let problem = ConjunctiveBooleanQuery::try_from(ConjunctiveBooleanQueryCreateSpec { + domain_size: 3, + relations: vec![Relation { + arity: 2, + tuples: vec![vec![0, 2]], + }], + conjuncts: vec![(0, vec![QueryArg::Variable(2), QueryArg::Constant(2)])], + }) + .unwrap(); + + assert_eq!(problem.num_variables(), 3); + assert_eq!( + ConjunctiveBooleanQueryCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["domain_size", "relations", "conjuncts"] + ); +} + +#[test] +fn test_conjunctivebooleanquery_create_spec_rejects_invalid_relation_index() { + let result = ConjunctiveBooleanQuery::try_from(ConjunctiveBooleanQueryCreateSpec { + domain_size: 1, + relations: vec![], + conjuncts: vec![(0, vec![])], + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs index 949f2c75a..168592693 100644 --- a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs @@ -1,4 +1,18 @@ use super::*; + +#[test] +fn create_spec_defaults_known_values() { + let problem = ConsistencyOfDatabaseFrequencyTables::try_from( + ConsistencyOfDatabaseFrequencyTablesCreateSpec { + num_objects: 2, + attribute_domains: vec![2, 2], + frequency_tables: vec![FrequencyTable::new(0, 1, vec![vec![1, 0], vec![0, 1]])], + known_values: None, + }, + ) + .unwrap(); + assert!(problem.known_values().is_empty()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/grouping_by_swapping.rs b/src/unit_tests/models/misc/grouping_by_swapping.rs index 1436988be..56e45c250 100644 --- a/src/unit_tests/models/misc/grouping_by_swapping.rs +++ b/src/unit_tests/models/misc/grouping_by_swapping.rs @@ -106,3 +106,34 @@ fn test_grouping_by_swapping_symbol_out_of_range_panics() { fn test_grouping_by_swapping_empty_string_requires_zero_budget() { GroupingBySwapping::new(0, vec![], 1); } + +#[test] +fn test_grouping_by_swapping_create_spec_derives_alphabet_and_renames_bound() { + let problem = GroupingBySwapping::try_from(GroupingBySwappingCreateSpec { + alphabet_size: None, + string: vec![0, 2, 1], + bound: 4, + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.budget(), 4); + assert_eq!( + GroupingBySwappingCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "string", "bound"] + ); +} + +#[test] +fn test_grouping_by_swapping_create_spec_rejects_nonzero_bound_for_empty_string() { + let result = GroupingBySwapping::try_from(GroupingBySwappingCreateSpec { + alphabet_size: None, + string: vec![], + bound: 1, + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/job_shop_scheduling.rs b/src/unit_tests/models/misc/job_shop_scheduling.rs index 4b252f1d5..a3560fa75 100644 --- a/src/unit_tests/models/misc/job_shop_scheduling.rs +++ b/src/unit_tests/models/misc/job_shop_scheduling.rs @@ -91,3 +91,36 @@ fn test_job_shop_scheduling_brute_force_solver_small_instance() { let witness = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&witness), Min(Some(2))); } + +#[test] +fn test_job_shop_scheduling_create_spec_derives_processor_count() { + let problem = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![vec![(0, 2), (2, 1)]], + num_processors: None, + }) + .unwrap(); + + assert_eq!(problem.num_processors(), 3); + assert_eq!( + JobShopSchedulingCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["jobs", "num_processors"] + ); +} + +#[test] +fn test_job_shop_scheduling_create_spec_rejects_invalid_jobs() { + let empty = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![], + num_processors: None, + }); + assert!(empty.is_err()); + + let repeated_processor = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![vec![(0, 1), (0, 2)]], + num_processors: Some(1), + }); + assert!(repeated_processor.is_err()); +} diff --git a/src/unit_tests/models/misc/knapsack.rs b/src/unit_tests/models/misc/knapsack.rs index ec75b7077..32f1e54ef 100644 --- a/src/unit_tests/models/misc/knapsack.rs +++ b/src/unit_tests/models/misc/knapsack.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_item_weights() { + let p = Knapsack::try_from(KnapsackCreateSpec { + weights: None, + values: vec![2, 3], + capacity: 1, + }) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/kth_largest_m_tuple.rs b/src/unit_tests/models/misc/kth_largest_m_tuple.rs index 57e252576..0afabef1d 100644 --- a/src/unit_tests/models/misc/kth_largest_m_tuple.rs +++ b/src/unit_tests/models/misc/kth_largest_m_tuple.rs @@ -1,4 +1,4 @@ -use crate::models::misc::KthLargestMTuple; +use super::*; use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; use crate::types::Or; @@ -8,6 +8,18 @@ fn example_problem(k: u64) -> KthLargestMTuple { KthLargestMTuple::new(vec![vec![2, 5, 8], vec![3, 6], vec![1, 4, 7]], k, 12) } +#[test] +fn test_kth_largest_m_tuple_create_spec_uses_subsets_input() { + assert_eq!(KthLargestMTupleCreateSpec::FIELDS[0].name, "subsets"); + let problem = KthLargestMTuple::try_from(KthLargestMTupleCreateSpec { + subsets: vec![vec![1], vec![2]], + k: 1, + bound: 3, + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![1], vec![2]]); +} + #[test] fn test_kth_largest_m_tuple_creation() { let p = example_problem(14); diff --git a/src/unit_tests/models/misc/longest_common_subsequence.rs b/src/unit_tests/models/misc/longest_common_subsequence.rs index 83747828f..56ec2e7ce 100644 --- a/src/unit_tests/models/misc/longest_common_subsequence.rs +++ b/src/unit_tests/models/misc/longest_common_subsequence.rs @@ -159,3 +159,32 @@ fn test_lcs_full_length_witness() { assert_eq!(problem.max_length(), 2); assert_eq!(problem.evaluate(&[0, 1]), Max(Some(2))); } + +#[test] +fn test_lcs_create_spec_derives_internal_fields() { + let problem = LongestCommonSubsequence::try_from(LongestCommonSubsequenceCreateSpec { + alphabet_size: None, + strings: vec![vec![0, 2], vec![2, 1, 0]], + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.max_length(), 2); + assert_eq!( + LongestCommonSubsequenceCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "strings"] + ); +} + +#[test] +fn test_lcs_create_spec_rejects_all_empty_strings() { + let result = LongestCommonSubsequence::try_from(LongestCommonSubsequenceCreateSpec { + alphabet_size: Some(2), + strings: vec![vec![], vec![]], + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/minimum_decision_tree.rs b/src/unit_tests/models/misc/minimum_decision_tree.rs index 6332ff56c..4d8e342e6 100644 --- a/src/unit_tests/models/misc/minimum_decision_tree.rs +++ b/src/unit_tests/models/misc/minimum_decision_tree.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_rejects_indistinguishable_objects() { + assert!( + MinimumDecisionTree::try_from(MinimumDecisionTreeCreateSpec { + test_matrix: vec![vec![false, false]], + num_objects: 2, + num_tests: 1 + }) + .is_err() + ); +} use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs index 704471b8d..83795f884 100644 --- a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs +++ b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs @@ -236,3 +236,21 @@ fn test_minimum_tardiness_sequencing_paper_example() { let problem = MinimumTardinessSequencing::::new(4, vec![2, 3, 1, 4], vec![(0, 2)]); assert_eq!(problem.evaluate(&[0, 0, 0, 0]), Min(Some(1))); } +#[test] +fn create_specs_default_precedences_to_empty() { + let unit = MinimumTardinessSequencing::try_from(MinimumTardinessSequencingOneCreateSpec { + lengths: vec![One, One], + deadlines: vec![1, 2], + precedences: None, + }) + .unwrap(); + let weighted = MinimumTardinessSequencing::try_from(MinimumTardinessSequencingI32CreateSpec { + lengths: vec![1, 2], + deadlines: vec![1, 3], + precedences: None, + }) + .unwrap(); + assert!(unit.precedences().is_empty()); + assert!(weighted.precedences().is_empty()); + assert!(!MinimumTardinessSequencingOneCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs index 1a708e8cf..f36009fb7 100644 --- a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs +++ b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_arc_weights() { + let p = MinimumWeightAndOrGraph::try_from(MinimumWeightAndOrGraphCreateSpec { + num_vertices: 2, + arcs: vec![(0, 1)], + source: 0, + gate_types: vec![Some(false), None], + arc_weights: None, + }) + .unwrap(); + assert_eq!(p.arc_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/multiprocessor_scheduling.rs b/src/unit_tests/models/misc/multiprocessor_scheduling.rs index bbc5ff3d8..e3aad6483 100644 --- a/src/unit_tests/models/misc/multiprocessor_scheduling.rs +++ b/src/unit_tests/models/misc/multiprocessor_scheduling.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_zero_processors() { + assert_eq!( + MultiprocessorSchedulingCreateSpec::FIELDS[1].name, + "num_processors" + ); + assert!( + MultiprocessorScheduling::try_from(MultiprocessorSchedulingCreateSpec { + lengths: vec![1], + num_processors: 0, + deadline: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/open_shop_scheduling.rs b/src/unit_tests/models/misc/open_shop_scheduling.rs index 2c493cdac..18e1e174b 100644 --- a/src/unit_tests/models/misc/open_shop_scheduling.rs +++ b/src/unit_tests/models/misc/open_shop_scheduling.rs @@ -10,6 +10,20 @@ fn two_by_two() -> OpenShopScheduling { OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]) } +#[test] +fn test_open_shop_create_spec_uses_num_processors_input() { + assert_eq!( + OpenShopSchedulingCreateSpec::FIELDS[0].name, + "num_processors" + ); + let problem = OpenShopScheduling::try_from(OpenShopSchedulingCreateSpec { + num_processors: 2, + processing_times: vec![vec![1, 2]], + }) + .unwrap(); + assert_eq!(problem.num_machines(), 2); +} + /// 3 machines, 3 jobs: a small asymmetric instance. fn three_by_three() -> OpenShopScheduling { OpenShopScheduling::new(3, vec![vec![1, 2, 3], vec![3, 2, 1], vec![2, 1, 2]]) diff --git a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs index a354ec3aa..6949af168 100644 --- a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs +++ b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = + OptimumCommunicationSpanningTree::try_from(OptimumCommunicationSpanningTreeCreateSpec { + num_vertices: 2, + edge_weights: None, + requirements: vec![vec![0, 1], vec![1, 0]], + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[vec![0, 1], vec![1, 0]]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/partially_ordered_knapsack.rs b/src/unit_tests/models/misc/partially_ordered_knapsack.rs index 9c8f907f2..47f9dbe19 100644 --- a/src/unit_tests/models/misc/partially_ordered_knapsack.rs +++ b/src/unit_tests/models/misc/partially_ordered_knapsack.rs @@ -200,3 +200,15 @@ fn test_partially_ordered_knapsack_negative_weight() { fn test_partially_ordered_knapsack_negative_value() { PartiallyOrderedKnapsack::new(vec![1, 2], vec![-3, 4], vec![], 5); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = PartiallyOrderedKnapsack::try_from(PartiallyOrderedKnapsackCreateSpec { + weights: vec![1, 2], + values: vec![3, 4], + precedences: None, + capacity: 2, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PartiallyOrderedKnapsackCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs index 8c30bc4b3..1d42716ad 100644 --- a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs @@ -133,3 +133,16 @@ fn test_precedence_constrained_scheduling_no_precedences() { .expect("should find a solution"); assert!(problem.evaluate(&solution)); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = + PrecedenceConstrainedScheduling::try_from(PrecedenceConstrainedSchedulingCreateSpec { + num_tasks: 2, + num_processors: 1, + deadline: 2, + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PrecedenceConstrainedSchedulingCreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/misc/preemptive_scheduling.rs b/src/unit_tests/models/misc/preemptive_scheduling.rs index d1e2f8809..f7673c6bb 100644 --- a/src/unit_tests/models/misc/preemptive_scheduling.rs +++ b/src/unit_tests/models/misc/preemptive_scheduling.rs @@ -229,3 +229,14 @@ fn test_preemptive_scheduling_deserialize_invalid_zero_processors() { let result: Result = serde_json::from_value(json); assert!(result.is_err()); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = PreemptiveScheduling::try_from(PreemptiveSchedulingCreateSpec { + lengths: vec![1, 2], + num_processors: 1, + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PreemptiveSchedulingCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/production_planning.rs b/src/unit_tests/models/misc/production_planning.rs index 83be19188..f8ec13e50 100644 --- a/src/unit_tests/models/misc/production_planning.rs +++ b/src/unit_tests/models/misc/production_planning.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_rejects_period_vector_mismatch() { + assert_eq!(ProductionPlanningCreateSpec::FIELDS[0].name, "num_periods"); + assert!(ProductionPlanning::try_from(ProductionPlanningCreateSpec { + num_periods: 1, + demands: vec![], + capacities: vec![1], + setup_costs: vec![1], + production_costs: vec![1], + inventory_costs: vec![1], + cost_bound: 1 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Or; diff --git a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs index d137878c6..bae28193d 100644 --- a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_task_weights() { + let p = SchedulingToMinimizeWeightedCompletionTime::try_from( + SchedulingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: vec![1, 2], + weights: None, + num_processors: 1, + }, + ) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs index 19ae0cab4..972fed615 100644 --- a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_deadline_count_mismatch() { + assert_eq!( + SchedulingWithIndividualDeadlinesCreateSpec::FIELDS[2].name, + "deadlines" + ); + assert!(SchedulingWithIndividualDeadlines::try_from( + SchedulingWithIndividualDeadlinesCreateSpec { + num_tasks: 2, + num_processors: 1, + deadlines: vec![1], + precedences: None + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; @@ -132,3 +149,16 @@ fn test_scheduling_with_individual_deadlines_mismatched_deadlines() { fn test_scheduling_with_individual_deadlines_invalid_precedence() { SchedulingWithIndividualDeadlines::new(3, 2, vec![1, 1, 1], vec![(4, 1)]); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = + SchedulingWithIndividualDeadlines::try_from(SchedulingWithIndividualDeadlinesCreateSpec { + num_tasks: 2, + num_processors: 1, + deadlines: vec![1, 2], + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!SchedulingWithIndividualDeadlinesCreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index a463b1a9b..b4d005fe8 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_precedences() { + let problem = + SequencingToMinimizeMaximumCumulativeCost::try_from(SequencingCumulativeCostCreateSpec { + costs: vec![1, -1], + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs index 023b18c75..7b93dd1e9 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_task_weights() { + let p = SequencingToMinimizeTardyTaskWeight::try_from( + SequencingToMinimizeTardyTaskWeightCreateSpec { + lengths: vec![1, 2], + weights: None, + deadlines: vec![1, 3], + }, + ) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 58a40b00a..4eef77567 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -198,3 +198,16 @@ fn test_sequencing_to_minimize_weighted_completion_time_total_processing_time_ov SequencingToMinimizeWeightedCompletionTime::new(vec![u64::MAX, 1], vec![1, 1], vec![]); let _ = problem.total_processing_time(); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = SequencingToMinimizeWeightedCompletionTime::try_from( + SequencingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: vec![1, 2], + weights: vec![3, 4], + precedences: None, + }, + ) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!SequencingToMinimizeWeightedCompletionTimeCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs index b0d45b8f4..ea5bca499 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_vector_length_mismatch() { + assert_eq!( + SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS[1].name, + "weights" + ); + assert!(SequencingToMinimizeWeightedTardiness::try_from( + SequencingToMinimizeWeightedTardinessCreateSpec { + lengths: vec![1], + weights: vec![], + deadlines: vec![1], + bound: 0 + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/sequencing_within_intervals.rs b/src/unit_tests/models/misc/sequencing_within_intervals.rs index a9a60ac2f..71410517c 100644 --- a/src/unit_tests/models/misc/sequencing_within_intervals.rs +++ b/src/unit_tests/models/misc/sequencing_within_intervals.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_empty_window() { + assert_eq!( + SequencingWithinIntervalsCreateSpec::FIELDS[0].name, + "release_times" + ); + assert!( + SequencingWithinIntervals::try_from(SequencingWithinIntervalsCreateSpec { + release_times: vec![2], + deadlines: vec![2], + lengths: vec![1] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/shortest_common_supersequence.rs b/src/unit_tests/models/misc/shortest_common_supersequence.rs index 3a6117f9a..0d0bc8fd9 100644 --- a/src/unit_tests/models/misc/shortest_common_supersequence.rs +++ b/src/unit_tests/models/misc/shortest_common_supersequence.rs @@ -3,6 +3,57 @@ use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; +#[test] +fn test_shortestcommonsupersequence_create_spec_derives_stored_fields() { + let problem = ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![vec![0, 1], vec![1, 2]], + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.strings(), &[vec![0, 1], vec![1, 2]]); + assert_eq!(problem.max_length(), 4); + + let entry = inventory::iter::() + .find(|entry| entry.name == "ShortestCommonSupersequence") + .unwrap(); + let inputs = entry.create_inputs.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].name, "strings"); + assert_eq!( + inputs[0].codec, + crate::registry::CreateInputCodec::SemicolonSeparated + ); + + let constructed = (entry.construct_fn)(serde_json::json!({ + "strings": [[0, 1], [1, 2]] + })) + .unwrap(); + let constructed = constructed + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(constructed.alphabet_size(), 3); + assert_eq!(constructed.max_length(), 4); +} + +#[test] +fn test_shortestcommonsupersequence_create_spec_rejects_invalid_input() { + let empty = ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![], + }); + assert_eq!(empty.unwrap_err(), "must have at least one string"); + + let overflowing_symbol = + ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![vec![usize::MAX]], + }); + assert_eq!( + overflowing_symbol.unwrap_err(), + "alphabet size overflows usize" + ); +} + #[test] fn test_shortestcommonsupersequence_basic() { let problem = ShortestCommonSupersequence::new( diff --git a/src/unit_tests/models/misc/stacker_crane.rs b/src/unit_tests/models/misc/stacker_crane.rs index bfc89f9b6..e2b6a4262 100644 --- a/src/unit_tests/models/misc/stacker_crane.rs +++ b/src/unit_tests/models/misc/stacker_crane.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_defaults_lengths_and_checks_inferred_vertex_counts() { + let problem = StackerCrane::try_from(StackerCraneCreateSpec { + arcs: vec![(0, 1)], + edges: vec![(1, 0)], + num_vertices: None, + arc_lengths: None, + edge_lengths: None, + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 2); + assert_eq!(problem.arc_lengths(), &[1]); + assert_eq!(problem.edge_lengths(), &[1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/staff_scheduling.rs b/src/unit_tests/models/misc/staff_scheduling.rs index 7e36b205f..f363f03eb 100644 --- a/src/unit_tests/models/misc/staff_scheduling.rs +++ b/src/unit_tests/models/misc/staff_scheduling.rs @@ -2,6 +2,19 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_staff_scheduling_create_spec_uses_k_input() { + assert_eq!(StaffSchedulingCreateSpec::FIELDS[0].name, "k"); + let problem = StaffScheduling::try_from(StaffSchedulingCreateSpec { + k: 1, + schedules: vec![vec![true, false]], + requirements: vec![1, 0], + num_workers: 1, + }) + .unwrap(); + assert_eq!(problem.shifts_per_schedule(), 1); +} + fn issue_example_problem() -> StaffScheduling { StaffScheduling::new( 5, diff --git a/src/unit_tests/models/misc/string_to_string_correction.rs b/src/unit_tests/models/misc/string_to_string_correction.rs index f4023a730..ba6320604 100644 --- a/src/unit_tests/models/misc/string_to_string_correction.rs +++ b/src/unit_tests/models/misc/string_to_string_correction.rs @@ -149,3 +149,37 @@ fn test_string_to_string_correction_is_available_in_prelude() { let problem = crate::prelude::StringToStringCorrection::new(2, vec![0], vec![0], 0); assert!(problem.evaluate(&[])); } + +#[test] +fn test_string_to_string_correction_create_spec_derives_alphabet() { + let problem = StringToStringCorrection::try_from(StringToStringCorrectionCreateSpec { + alphabet_size: None, + source_string: vec![0, 3], + target_string: vec![3], + bound: 1, + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 4); + assert_eq!(problem.source(), &[0, 3]); + assert_eq!(problem.target(), &[3]); + assert_eq!( + StringToStringCorrectionCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "source_string", "target_string", "bound"] + ); +} + +#[test] +fn test_string_to_string_correction_create_spec_rejects_small_alphabet() { + let result = StringToStringCorrection::try_from(StringToStringCorrectionCreateSpec { + alphabet_size: Some(2), + source_string: vec![2], + target_string: vec![], + bound: 1, + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/three_partition.rs b/src/unit_tests/models/misc/three_partition.rs index af70099a1..a8fc62e77 100644 --- a/src/unit_tests/models/misc/three_partition.rs +++ b/src/unit_tests/models/misc/three_partition.rs @@ -21,6 +21,20 @@ fn test_three_partition_basic() { assert_eq!(::variant(), vec![]); } +#[test] +fn test_three_partition_create_spec_preserves_u64_bound() { + let entry = crate::registry::find_variant_entry("ThreePartition", &Default::default()).unwrap(); + let problem = (entry.construct_fn)(serde_json::json!({ + "sizes": vec![6148914691236517205_u64; 3], + "bound": u64::MAX, + })) + .unwrap(); + assert_eq!( + problem.serialize_json()["bound"], + serde_json::json!(u64::MAX) + ); +} + #[test] fn test_three_partition_evaluate_yes_instance() { let problem = yes_problem(); diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 82f52d032..aba2eea19 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,4 +1,18 @@ -use crate::models::misc::TimetableDesign; +use super::*; + +#[test] +fn create_spec_rejects_matrix_shape_mismatch() { + assert_eq!(TimetableDesignCreateSpec::FIELDS[3].name, "craftsman_avail"); + assert!(TimetableDesign::try_from(TimetableDesignCreateSpec { + num_periods: 1, + num_craftsmen: 1, + num_tasks: 1, + craftsman_avail: vec![], + task_avail: vec![vec![true]], + requirements: vec![vec![1]] + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/set/comparative_containment.rs b/src/unit_tests/models/set/comparative_containment.rs index c677fd45e..66d444b00 100644 --- a/src/unit_tests/models/set/comparative_containment.rs +++ b/src/unit_tests/models/set/comparative_containment.rs @@ -1,4 +1,27 @@ use super::*; + +#[test] +fn create_spec_defaults_weights_and_validates_sets() { + let problem = ComparativeContainment::::try_from(ComparativeContainmentI32CreateSpec { + universe_size: 2, + r_sets: vec![vec![0]], + s_sets: vec![vec![1]], + r_weights: None, + s_weights: None, + }) + .unwrap(); + assert_eq!(problem.r_weights(), &[1]); + assert!( + ComparativeContainment::::try_from(ComparativeContainmentI32CreateSpec { + universe_size: 1, + r_sets: vec![vec![1]], + s_sets: vec![], + r_weights: None, + s_weights: None + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::One; diff --git a/src/unit_tests/models/set/exact_cover_by_3_sets.rs b/src/unit_tests/models/set/exact_cover_by_3_sets.rs index fef828bfb..aad0abb88 100644 --- a/src/unit_tests/models/set/exact_cover_by_3_sets.rs +++ b/src/unit_tests/models/set/exact_cover_by_3_sets.rs @@ -1,4 +1,13 @@ use super::*; +#[test] +fn create_spec_sorts_triples() { + let problem = ExactCoverBy3Sets::try_from(ExactCoverBy3SetsCreateSpec { + universe_size: 3, + subsets: vec![[2, 0, 1]], + }) + .unwrap(); + assert_eq!(problem.subsets(), &[[0, 1, 2]]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/set/maximum_set_packing.rs b/src/unit_tests/models/set/maximum_set_packing.rs index 3f5a67f48..edda4ab10 100644 --- a/src/unit_tests/models/set/maximum_set_packing.rs +++ b/src/unit_tests/models/set/maximum_set_packing.rs @@ -4,6 +4,21 @@ use crate::traits::Problem; use crate::types::Max; include!("../../jl_helpers.rs"); +#[test] +fn test_maximum_set_packing_create_spec_uses_subsets_input() { + assert_eq!( + MaximumSetPackingCreateSpec::::FIELDS[0].name, + "subsets" + ); + let problem = MaximumSetPacking::try_from(MaximumSetPackingCreateSpec { + subsets: vec![vec![0], vec![1]], + weights: vec![2, 3], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0], vec![1]]); + assert_eq!(problem.weights_ref(), &[2, 3]); +} + #[test] fn test_set_packing_creation() { let problem = MaximumSetPacking::::new(vec![vec![0, 1], vec![1, 2], vec![3, 4]]); diff --git a/src/unit_tests/models/set/minimum_hitting_set.rs b/src/unit_tests/models/set/minimum_hitting_set.rs index 576f39b44..b132821dc 100644 --- a/src/unit_tests/models/set/minimum_hitting_set.rs +++ b/src/unit_tests/models/set/minimum_hitting_set.rs @@ -20,6 +20,17 @@ fn issue_example_problem() -> MinimumHittingSet { ) } +#[test] +fn test_minimum_hitting_set_create_spec_uses_subsets_input() { + assert_eq!(MinimumHittingSetCreateSpec::FIELDS[1].name, "subsets"); + let problem = MinimumHittingSet::try_from(MinimumHittingSetCreateSpec { + universe_size: 3, + subsets: vec![vec![0, 2]], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0, 2]]); +} + fn issue_example_config() -> Vec { vec![0, 1, 0, 1, 1, 0] } diff --git a/src/unit_tests/models/set/minimum_set_covering.rs b/src/unit_tests/models/set/minimum_set_covering.rs index bee8eeed5..5878d4062 100644 --- a/src/unit_tests/models/set/minimum_set_covering.rs +++ b/src/unit_tests/models/set/minimum_set_covering.rs @@ -4,6 +4,19 @@ use crate::traits::Problem; use crate::types::Min; include!("../../jl_helpers.rs"); +#[test] +fn test_minimum_set_covering_create_spec_uses_subsets_input() { + assert_eq!(MinimumSetCoveringCreateSpec::FIELDS[1].name, "subsets"); + let problem = MinimumSetCovering::try_from(MinimumSetCoveringCreateSpec { + universe_size: 2, + subsets: vec![vec![0], vec![1]], + weights: vec![2, 3], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0], vec![1]]); + assert_eq!(problem.weights_ref(), &[2, 3]); +} + #[test] fn test_set_covering_creation() { let problem = MinimumSetCovering::::new(4, vec![vec![0, 1], vec![1, 2], vec![2, 3]]); diff --git a/src/unit_tests/models/set/prime_attribute_name.rs b/src/unit_tests/models/set/prime_attribute_name.rs index b8999597c..6676e12af 100644 --- a/src/unit_tests/models/set/prime_attribute_name.rs +++ b/src/unit_tests/models/set/prime_attribute_name.rs @@ -2,6 +2,21 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_prime_attribute_create_spec_uses_universe_size_input() { + assert_eq!( + PrimeAttributeNameCreateSpec::FIELDS[0].name, + "universe_size" + ); + let problem = PrimeAttributeName::try_from(PrimeAttributeNameCreateSpec { + universe_size: 2, + dependencies: vec![(vec![0], vec![1])], + query_attribute: 0, + }) + .unwrap(); + assert_eq!(problem.num_attributes(), 2); +} + /// Helper: Issue Example 1 — 6 attributes, 3 FDs, query=3 /// Candidate keys: {0,1}, {2,3}, {0,3} — attribute 3 is prime fn example1() -> PrimeAttributeName { diff --git a/src/unit_tests/models/set/set_basis.rs b/src/unit_tests/models/set/set_basis.rs index ff4bb3a27..08427367f 100644 --- a/src/unit_tests/models/set/set_basis.rs +++ b/src/unit_tests/models/set/set_basis.rs @@ -11,6 +11,18 @@ fn issue_example_problem(k: usize) -> SetBasis { ) } +#[test] +fn test_set_basis_create_spec_uses_subsets_input() { + assert_eq!(SetBasisCreateSpec::FIELDS[1].name, "subsets"); + let problem = SetBasis::try_from(SetBasisCreateSpec { + universe_size: 3, + subsets: vec![vec![0, 2]], + k: 1, + }) + .unwrap(); + assert_eq!(problem.collection(), &[vec![0, 2]]); +} + fn canonical_solution() -> Vec { vec![1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0] } diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 44bac3c0d..8950d9332 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -76,9 +76,10 @@ fn test_schema_json_serialization() { fn test_field_info_json_fields() { let schemas = collect_schemas(); let sg = schemas.iter().find(|s| s.name == "SpinGlass").unwrap(); - assert_eq!(sg.fields.len(), 3); + assert_eq!(sg.fields.len(), 4); let field_names: Vec<&str> = sg.fields.iter().map(|f| f.name.as_str()).collect(); assert!(field_names.contains(&"graph")); + assert!(field_names.contains(&"num_vertices")); assert!(field_names.contains(&"couplings")); assert!(field_names.contains(&"fields")); for f in &sg.fields { diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index f8ec9d944..ec36272f4 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -1,5 +1,8 @@ -use crate::registry::variant::{validate_variant_aliases, variant_label}; -use std::collections::BTreeMap; +use crate::registry::variant::{ + validate_create_inputs, validate_direct_create_inputs, validate_variant_aliases, variant_label, +}; +use crate::registry::{ConstructionError, CreateInputCodec, CreateInputInfo, FieldInfo}; +use std::collections::{BTreeMap, BTreeSet}; #[test] fn variant_alias_inventory_is_valid() { @@ -16,6 +19,193 @@ fn empty_problem_names() -> BTreeMap> { BTreeMap::new() } +const CREATE_INPUTS: &[CreateInputInfo] = &[ + CreateInputInfo { + name: "required_value", + type_name: "usize", + description: "A required value", + required: true, + codec: CreateInputCodec::Scalar, + }, + CreateInputInfo { + name: "optional_value", + type_name: "usize", + description: "An optional value", + required: false, + codec: CreateInputCodec::Scalar, + }, +]; + +#[test] +fn construction_contract_accepts_declared_inputs() { + let data = serde_json::json!({"required_value": 1, "optional_value": 2}); + assert_eq!(validate_create_inputs(CREATE_INPUTS, &data), Ok(())); +} + +#[test] +fn construction_contract_rejects_unknown_inputs() { + let data = serde_json::json!({"required_value": 1, "removed_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::UnknownInputs(vec![ + "removed_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_missing_required_inputs() { + let data = serde_json::json!({"optional_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::MissingInputs(vec![ + "required_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_non_object_values() { + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &serde_json::json!([])), + Err(ConstructionError::ExpectedObject) + ); +} + +#[test] +fn construction_contract_rejects_duplicate_declarations() { + let duplicate = [CREATE_INPUTS[0], CREATE_INPUTS[0]]; + assert_eq!( + validate_create_inputs(&duplicate, &serde_json::json!({"required_value": 1})), + Err(ConstructionError::DuplicateInput( + "required_value".to_string() + )) + ); +} + +#[test] +fn catalog_custom_construction_metadata_is_well_formed() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + let label = variant_label(entry); + let mut names = BTreeSet::new(); + for input in inputs { + assert!( + !input.name.is_empty(), + "{label} declares an empty construction input name" + ); + assert!( + input + .name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()), + "{label} construction input `{}` must use snake_case", + input.name + ); + assert!( + names.insert(input.name), + "{label} declares construction input `{}` more than once", + input.name + ); + assert!( + !input.type_name.trim().is_empty(), + "{label} construction input `{}` has no Rust type", + input.name + ); + assert_eq!( + input.description, + input.description.trim(), + "{label} construction input `{}` has surrounding whitespace in its description", + input.name + ); + } + } +} + +#[test] +fn default_custom_construction_inputs_match_catalog_schema_fields() { + for entry in inventory::iter::() + .filter(|entry| entry.is_default && entry.create_inputs.is_some()) + { + let schema = inventory::iter::() + .find(|schema| schema.name == entry.name) + .unwrap_or_else(|| panic!("{} has no ProblemSchemaEntry", entry.name)); + let schema_names = schema + .fields + .iter() + .map(|field| field.name) + .collect::>(); + let input_names = entry + .create_inputs + .unwrap() + .iter() + .map(|input| input.name) + .collect::>(); + assert_eq!( + schema_names, + input_names, + "default variant {} catalog fields differ from its construction inputs", + variant_label(entry) + ); + } +} + +#[test] +fn every_custom_construction_contract_rejects_unknown_and_missing_inputs() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + assert_eq!( + validate_create_inputs(inputs, &serde_json::json!({"unknown_input": null})), + Err(ConstructionError::UnknownInputs(vec![ + "unknown_input".to_string() + ])), + "{} accepted an undeclared construction input", + variant_label(entry) + ); + + let required = inputs + .iter() + .filter(|input| input.required) + .map(|input| input.name.to_string()) + .collect::>() + .into_iter() + .collect::>(); + let result = validate_create_inputs(inputs, &serde_json::json!({})); + if required.is_empty() { + assert_eq!( + result, + Ok(()), + "{} rejected an empty payload", + variant_label(entry) + ); + } else { + assert_eq!( + result, + Err(ConstructionError::MissingInputs(required)), + "{} did not report all missing required inputs", + variant_label(entry) + ); + } + } +} + +#[test] +fn construction_contract_direct_fields_are_required() { + let fields = [FieldInfo { + name: "value", + type_name: "usize", + description: "Stored value", + }]; + assert_eq!( + validate_direct_create_inputs(&fields, &serde_json::json!({})), + Err(ConstructionError::MissingInputs(vec!["value".to_string()])) + ); +} + #[test] fn validate_inner_accepts_valid_aliases() { let entries = vec![