diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ef69387f1..f3f3c7dbc 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -151,9 +151,9 @@ 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. +- `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 an explicit structural `category` plus `dims`, `fields`, and `size_getters` parameters for problem-specific size fields. - Problems parameterized by graph type `G` and optionally weight type `W` (problem-dependent) - `Solver::solve()` computes the aggregate value for any `Problem` whose `Value` implements `Aggregate` - `BruteForce::find_witness()` / `find_all_witnesses()` recover witnesses only when `P::Value::supports_witnesses()` @@ -204,9 +204,11 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI -- **CLI creation is schema-driven:** `pred create` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. New models need only: (1) matching CLI flags in `CreateArgs` + `flag_map()`, and (2) type parser support in `parse_field_value()` if using a new field type. No match arm in `create.rs` is needed. -- **CLI flag names must match schema field names.** The canonical name for a CLI flag is the schema field name in kebab-case (e.g., schema field `universe_size` → `--universe-size`, field `subsets` → `--subsets`). Old aliases (e.g., `--universe`, `--sets`) may exist as clap `alias` for backward compatibility at the clap level, but `flag_map()`, help text, error messages, and documentation must use the schema-derived name. Do not add new backward-compat aliases; if a field is renamed in the schema, update the CLI flag name to match. -- **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}`. +- **Model category is explicit registry metadata.** Every `ProblemSchemaEntry` declares exactly one of `Algebraic`, `Formula`, `Graph`, `Misc`, or `Set`; catalog behavior never derives it from `module_path!()` or source location. +- **CLI creation is registry-driven and two-stage:** the static parser discovers the requested problem spec without registering model subcommands, then a second parse adds 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. +- **Random generation is optional and variant-owned.** Not every model has a useful, well-defined random-instance distribution. Add `RandomGenerate` only when the generator has clear semantics and a concrete use (for example, testing or examples); never invent arbitrary bounds or distributions merely to make every model support `--random`. Implement it beside the model (normally through `impl_random_generate!` and a typed `CreateSpec` input DTO), then add `random` only to the applicable `declare_variants!` entries. CLI and MCP discover the exact variant's inputs and callback; never add a model-name random dispatch or advertise random generation on an unsupported variant. +- **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..87e472566 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 (`display_name`, `aliases`, `dimensions`, explicit `category`, and construction `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 @@ -93,6 +92,8 @@ Choose the appropriate sub-module under `src/models/`: - `algebraic/` -- matrices, linear systems, lattices (QUBO, ILP, CVP, BMF) - `misc/` -- unique input structures that don't fit other categories (BinPacking, PaintShop, Factoring) +Declare the same structural choice explicitly in `ProblemSchemaEntry.category`. This is required metadata and is never inferred from `module_path!()` or the file location. + ## Step 1.5: Infer problem size getters From the **best known exact algorithm** complexity (item 9), infer what problem size getter methods the struct should expose. The variables used in the complexity expression define the natural size metrics. @@ -123,7 +124,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 include the explicit structural `category` and reflect the construction interface through `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 @@ -169,22 +170,32 @@ The CLI now loads, serializes, and brute-force solves problems through the core 1. **Registry-backed dispatch comes from `declare_variants!`:** - Make sure every concrete variant you want the CLI to load is listed in `declare_variants!` - Mark the intended default variant with `default` when applicable + - Declare well-established problem aliases in `ProblemSchemaEntry.aliases` and variant-specific aliases in `declare_variants!`; CLI and MCP discover both from the registry + +## Step 4.5: Add construction support + +CLI and MCP construction are registry-driven. Do not edit either frontend to recognize a model name. + +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. 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. -2. **`problemreductions-cli/src/problem_name.rs`:** - - 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) +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. -## Step 4.5: Add CLI creation support +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. -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. +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. -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. +### Optional random generation -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`. +Random generation is an optional model capability, not a model-completeness requirement. Many models do not have a natural or useful probability distribution over instances; leave random generation unregistered for those models. Do not invent arbitrary size limits, value ranges, or distributions merely to make `--random` available. -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. +When the model does have a well-defined generator with a concrete testing or example use, random generation is registry-driven and belongs beside the model. Do not edit CLI or MCP dispatch code. -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). +1. Define a typed random input DTO with `#[derive(Deserialize, CreateSpec)]`, or reuse a matching shared spec from `crate::random`. +2. Implement `RandomGenerate` with `crate::impl_random_generate!(ConcreteModel, RandomSpec, |spec| { ... })`. Validate values and return `Result`; do not round, clamp, or silently replace invalid inputs. +3. Add `random` only to the exact `declare_variants!` entries that implement the trait: `default Model => "..." create LocalCreateSpec random`. +4. The generated problem must have the same canonical name and variant as the selected registry entry. Use the concrete variant's actual graph and numeric types instead of attaching requested metadata to a different concrete instance. ## Step 4.6: Add canonical model example to example_db @@ -304,6 +315,7 @@ Structural and quality review is handled by the `review-pipeline` stage, not her |---------|-----| | Implementing weight management as a trait | Use inherent methods: `weights()`, `set_weights()`, `is_weighted()` | | Forgetting `inventory::submit!` | Every problem needs a `ProblemSchemaEntry` registration | +| Omitting or inferring the model category | Set the required `ProblemSchemaEntry.category` explicitly to one of `Algebraic`, `Formula`, `Graph`, `Misc`, or `Set`; never parse `module_path!()`. | | Missing `#[path]` test link | Add `#[cfg(test)] #[path = "..."] mod tests;` at file bottom | | Wrong `dims()` | Must match the actual configuration space (e.g., `vec![2; n]` for binary) | | Using the wrong aggregate wrapper | Objective models use `Max` / `Min` / `Extremum`, witness models use `bool`, aggregate-only models use a fold value like `Sum` / `And` | @@ -311,12 +323,12 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Forgetting `declare_variants!` | Required for variant complexity metadata and registry-backed load/serialize/solve dispatch | | Wrong aggregate wrapper | Use `Max` / `Min` / `Extremum` for objective problems, `Or` for existential witness problems, and `Sum` / `And` (or a custom aggregate) for value-only folds | | Wrong `declare_variants!` syntax | Entries no longer use `opt` / `sat`; one entry per problem may be marked `default` | -| Forgetting CLI alias | Must add lowercase entry in `problem_name.rs` `resolve_alias()` | +| Adding aliases in CLI code | Declare problem aliases in `ProblemSchemaEntry.aliases` and variant aliases in `declare_variants!` | | 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/.claude/skills/add-rule/SKILL.md b/.claude/skills/add-rule/SKILL.md index 11846e9de..c9862a188 100644 --- a/.claude/skills/add-rule/SKILL.md +++ b/.claude/skills/add-rule/SKILL.md @@ -267,7 +267,7 @@ Structural and quality review is handled by the `review-pipeline` stage, not her ## CLI Impact -Adding a witness-preserving reduction rule does NOT require CLI changes -- the reduction graph is auto-generated from `#[reduction]` macros and the CLI discovers paths dynamically. However, both source and target models must already be fully registered through their model files (`declare_variants!`), aliases as needed in `problem_name.rs`, and `pred create` support where applicable (see `add-model` skill). +Adding a witness-preserving reduction rule does NOT require CLI changes -- the reduction graph is auto-generated from `#[reduction]` macros and the CLI discovers paths dynamically. However, both source and target models must already be fully registered through their model files (`ProblemSchemaEntry` and `declare_variants!`), including any aliases and `pred create` construction contract (see `add-model` skill). `ExtractionError` already propagates through `pred extract` and bundle `pred solve`; add a rule-specific CLI test only when the CLI surface changes. @@ -296,6 +296,6 @@ Aggregate-only reductions currently have a narrower CLI surface: | Not adding a canonical example | Add the rule-local spec and include it from `src/rules/mod.rs` | | Not regenerating reduction graph | Run `cargo run --example export_graph` after adding a rule | | Skipping Step 6 (paper documentation) | **Every rule MUST have a `reduction-rule` entry in the paper. This is mandatory, not optional. PRs without documentation will be rejected.** | -| Source/target model not fully registered | Both problems must already have `declare_variants!`, aliases as needed, and CLI create support -- use `add-model` skill first | +| Source/target model not fully registered | Both problems must already have `ProblemSchemaEntry`, `declare_variants!`, registry aliases as needed, and a construction contract -- use `add-model` skill first | | Treating a direct-to-ILP rule as a toy stub | Direct ILP reductions need exact overhead metadata and strong semantic regression tests, just like other production ILP rules | | Skipping verification for complex reductions | Verification is default for a reason — `--no-verify` is for trivial identity/complement reductions only | diff --git a/.claude/skills/find-solver/SKILL.md b/.claude/skills/find-solver/SKILL.md index c9d221e36..704c76ced 100644 --- a/.claude/skills/find-solver/SKILL.md +++ b/.claude/skills/find-solver/SKILL.md @@ -86,9 +86,9 @@ Use `AskUserQuestion` for each question. Format options as **(a)**/**(b)**/**(c) 1. **Web search** the clarified problem description together with terms like "NP-hard", "computational complexity", or "reduction" to find formal problem names and known relationships in the literature. Use `WebSearch` tool. -2. **Run `pred list`** to get the full catalog of available models. Copy-paste the full output into your response. +2. **Search the catalog** with `pred list `. Use `pred list --json` when exhaustive machine-readable discovery is needed. Do not paste the full catalog into the response. -3. **Cross-reference** the web search results against the `pred list` catalog. For each candidate model that exists in the library (3-5 max), present a table: +3. **Cross-reference** the web search results against the catalog. For each candidate model that exists in the library (3-5 max), present a table: | # | Model | Why it might match | Caveat | |---|-------|--------------------|--------| diff --git a/.claude/skills/review-pipeline/SKILL.md b/.claude/skills/review-pipeline/SKILL.md index 9849a5432..51930ca79 100644 --- a/.claude/skills/review-pipeline/SKILL.md +++ b/.claude/skills/review-pipeline/SKILL.md @@ -175,7 +175,7 @@ Invoke `/review-quality` (file: `.claude/skills/review-quality/SKILL.md`) with t 2. **Invoke `/agentic-tests:test-feature`** (file: `~/.claude/commands/agentic-tests:test-feature.md`) with the identified feature. This simulates a downstream user exercising the feature from docs and examples. **Minimum test checklist** for the agentic tester: - - `pred list` — verify the new model/rule appears in the catalog + - For models, `pred list `; for rules, `pred list --rules ` — verify the new catalog entry appears - `pred show ` — verify details display correctly - `pred create --example ` — verify example instance creation works - `pred solve ` — verify solving works on the example diff --git a/.claude/skills/review-structural/SKILL.md b/.claude/skills/review-structural/SKILL.md index b22197d7c..85b7d55c8 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -61,8 +61,8 @@ Only run if review type includes "model". Given: problem name `P`, category `C`, | 9 | Registered in `{C}/mod.rs` | `Grep("mod {F}", "src/models/{C}/mod.rs")` | | 10 | Re-exported in `models/mod.rs` | `Grep("{P}", "src/models/mod.rs")` | | 11 | Variant registration exists | `Grep("declare_variants!|VariantEntry", file)` | -| 12 | CLI `resolve_alias` entry | `Grep("{P}", "problemreductions-cli/src/problem_name.rs")` | -| 13 | CLI `create` support | Schema-driven: verify each `ProblemSchemaEntry` field has a matching CLI flag in `CreateArgs` (field `snake_case` → flag `kebab-case`). Check `flag_map()` includes the flag. If the field type is unusual, verify `parse_field_value()` handles it. | +| 12 | Alias registration | If aliases are claimed, verify problem aliases are in `ProblemSchemaEntry.aliases` and variant aliases are in `declare_variants!`; no frontend alias branch | +| 13 | CLI `create` support | Run `pred create --help` for the concrete variant. Verify its flags and types come from the registered construction inputs (`ProblemSchemaEntry.fields` or the model-local `CreateSpec`), with a reusable codec for any unusual transport syntax. | | 14 | Canonical model example registered | `Grep("{P}", "src/example_db/model_builders.rs")` | | 15 | Paper `display-name` entry | `Grep('"{P}"', "docs/paper/reductions.typ")` | | 16 | Paper `problem-def` block | `Grep('problem-def.*"{P}"', "docs/paper/reductions.typ")` | diff --git a/.claude/skills/run-pipeline/SKILL.md b/.claude/skills/run-pipeline/SKILL.md index 1a0229a66..731b7e550 100644 --- a/.claude/skills/run-pipeline/SKILL.md +++ b/.claude/skills/run-pipeline/SKILL.md @@ -1,6 +1,6 @@ --- name: run-pipeline -description: Pick a Ready issue from the GitHub Project board, move it through In Progress -> issue-to-pr -> Review pool +description: Pick a Ready issue from the GitHub Project board, move it from In Progress through issue-to-pr into Review pool --- # Run Pipeline @@ -79,7 +79,7 @@ Score only **eligible** issues on three criteria. For `[Model]` issues, extract | Criterion | Weight | How to Assess | |-----------|--------|---------------| | **C1: Industrial/Theoretical Importance** | 3 | Read the report's issue summary for each eligible issue. Score 0-2: **2** = widely used in industry or foundational in complexity theory (e.g., ILP, SAT, MaxFlow, TSP, GraphColoring); **1** = moderately important or well-studied (e.g., SubsetSum, SetCover, Knapsack); **0** = niche or primarily academic | -| **C2: Related to Existing Problems** | 2 | Use the report's Ready/In-progress context plus `pred list` if needed. Score 0-2: **2** = directly related (shares input structure or has known reductions to/from ≥2 existing problems, but is NOT a trivial variant of an existing one); **1** = loosely related (same domain, connects to 1 existing problem); **0** = isolated or is essentially a variant/renaming of an existing problem | +| **C2: Related to Existing Problems** | 2 | Use the report's Ready/In-progress context plus `pred list ` or `pred list --json` if needed. Score 0-2: **2** = directly related (shares input structure or has known reductions to/from ≥2 existing problems, but is NOT a trivial variant of an existing one); **1** = loosely related (same domain, connects to 1 existing problem); **0** = isolated or is essentially a variant/renaming of an existing problem | | **C3: Unblocks Pending Rules** | 2 | Read the `Pending rules unblocked` count already printed in the report for each eligible issue. Score 0-2: **2** = unblocks ≥2 pending rules; **1** = unblocks 1 pending rule; **0** = does not unblock any pending rule | **Final score** = C1 × 3 + C2 × 2 + C3 × 2 (max = 12) diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index 5f2745859..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" @@ -20,7 +21,7 @@ mcp = ["dep:rmcp", "dep:tokio", "dep:schemars", "dep:tracing", "dep:tracing-subs [dependencies] problemreductions = { version = "0.6.0", path = "..", features = ["example-db"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "string"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 40f8fcb8c..78c5c024e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,7 +1,10 @@ -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; -use std::collections::HashMap; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; +use problemreductions::registry::ProblemCategory; +use std::ffi::OsString; use std::path::PathBuf; +pub use crate::create_args::CreateArgs; + #[derive(Parser)] #[command( name = "pred", @@ -46,18 +49,65 @@ pub struct Cli { pub command: Commands, } +impl Cli { + pub fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + pub fn try_parse_from(args: I) -> Result + where + I: IntoIterator, + T: Into, + { + // The discovery command treats the problem spec as an external subcommand, + // so it can capture the selected model without registering the whole catalog. + let args = args.into_iter().map(Into::into).collect::>(); + let command = ::command(); + let discovery_matches = command.clone().try_get_matches_from(args.clone())?; + let selected = discovery_matches + .subcommand_matches("create") + .and_then(|matches| matches.subcommand_name()); + + let mut matches = if let Some(selected) = selected { + crate::create_args::command_for_selected_problem(command, selected)? + .try_get_matches_from(args)? + } else { + discovery_matches + }; + Self::from_arg_matches_mut(&mut matches) + } +} + #[derive(Subcommand)] pub enum Commands { - /// List all registered problem types (or reduction rules with --rules) + /// Browse registered problem types (or reduction rules with --rules) #[command(after_help = "\ Examples: - pred list # list problem types - pred list --rules # list all reduction rules + pred list # show catalog summary and categories + pred list matching # search names and aliases + pred list --category graph # list graph problems + pred list --all # list every problem compactly + pred list --rules --all # list every reduction rule pred list -o problems.json # save as JSON")] List { + /// Case-insensitive substring to search in names and aliases + query: Option, + /// List reduction rules instead of problem types #[arg(long)] rules: bool, + + /// Restrict problems to a model category such as graph, set, or misc + #[arg(long, conflicts_with = "rules")] + category: Option, + + /// List the complete catalog instead of the summary + #[arg(long)] + all: bool, + + /// Include per-variant complexity, rule counts, or rule size contracts + #[arg(long)] + verbose: bool, }, /// Show details for a problem type or variant (fields, reductions, complexity) @@ -220,998 +270,6 @@ pub enum ExampleSide { Target, } -#[derive(clap::Args)] -#[command(after_help = "\ -TIP: Run `pred create ` (no other flags) to see problem-specific help. - Not every flag applies to every problem — the above list shows ALL flags. - -Flags by problem type: - MIS, MVC, MaxClique, MinDomSet --graph, --weights - MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights - LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex - HamiltonianPathBetweenTwoVertices --graph, --source-vertex, --target-vertex - ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --weight-bound - GraphPartitioning --graph, --num-partitions - MaximalIS --graph, --weights - SAT, NAESAT --num-vars, --clauses - KSAT --num-vars, --clauses [--k] - NonTautology --num-vars, --disjuncts - QUBO --matrix - SpinGlass --graph, --couplings, --fields - KColoring --graph, --k - KClique --graph, --k - DecisionMinimumVertexCover --graph, --weights, --bound - MinimumMultiwayCut --graph, --terminals, --edge-weights - MonochromaticTriangle --graph - PartitionIntoTriangles --graph - GeneralizedHex --graph, --source, --sink - IntegralFlowWithMultipliers --arcs, --capacities, --source, --sink, --multipliers, --requirement - MinimumEdgeCostFlow --arcs, --edge-weights (prices), --capacities, --source, --sink, --requirement - MinimumCostMaximumFlow --arcs, --capacities, --costs, --source, --sink - MinimumCostCirculation, MCC --arcs, --capacities, --costs - MinimumCutIntoBoundedSets --graph, --edge-weights, --source, --sink, --size-bound - HamiltonianCircuit, HC --graph - MaximumLeafSpanningTree --graph - LongestCircuit --graph, --edge-weights - BoundedComponentSpanningForest --graph, --weights, --k, --max-weight - UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement - IntegralFlowBundles --arcs, --bundles, --bundle-capacities, --source, --sink, --requirement [--num-vertices] - UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 - DisjointConnectingPaths --graph, --terminal-pairs - IntegralFlowHomologousArcs --arcs, --capacities, --source, --sink, --requirement, --homologous-pairs - IsomorphicSpanningTree --graph, --tree - KthBestSpanningTree --graph, --edge-weights, --k, --bound - LengthBoundedDisjointPaths --graph, --source, --sink, --max-length - PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement - Factoring --target, --m, --n - BinPacking --sizes, --capacity - Clustering --distance-matrix, --k, --diameter-bound - CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --cost-budget, --delay-budget - ProductionPlanning --num-periods, --demands, --capacities, --setup-costs, --production-costs, --inventory-costs, --cost-bound - SubsetProduct --sizes, --target - SubsetSum --sizes, --target - MinimumAxiomSet --n, --true-sentences, --implications - Numerical3DimensionalMatching --w-sizes, --x-sizes, --y-sizes, --bound - Betweenness --n, --sets (triples a,b,c) - CyclicOrdering --n, --sets (triples a,b,c) - ThreePartition --sizes, --bound - DynamicStorageAllocation --release-times, --deadlines, --sizes, --capacity - KthLargestMTuple --sets, --k, --bound - QuadraticCongruences --coeff-a, --coeff-b, --coeff-c - QuadraticDiophantineEquations --coeff-a, --coeff-b, --coeff-c - SimultaneousIncongruences --pairs (semicolon-separated a,b pairs) - SumOfSquaresPartition --sizes, --num-groups - ExpectedRetrievalCost --probabilities, --num-sectors - PaintShop --sequence - MaximumSetPacking --subsets [--weights] - MinimumHittingSet --universe-size, --subsets - MinimumSetCovering --universe-size, --subsets [--weights] - EnsembleComputation --universe-size, --subsets, --budget - ComparativeContainment --universe-size, --r-sets, --s-sets [--r-weights] [--s-weights] - X3C (ExactCoverBy3Sets) --universe-size, --subsets (3 elements each) - 3DM (ThreeDimensionalMatching) --universe-size, --subsets (triples w,x,y) - ThreeMatroidIntersection --universe-size, --partitions, --bound - SetBasis --universe-size, --subsets, --k - MinimumCardinalityKey --num-attributes, --dependencies - PrimeAttributeName --universe-size, --dependencies, --query-attribute - RootedTreeStorageAssignment --universe-size, --subsets, --bound - TwoDimensionalConsecutiveSets --alphabet-size, --subsets - BicliqueCover --left, --right, --biedges, --k - BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k - BiconnectivityAugmentation --graph, --potential-weights, --budget [--num-vertices] - PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices] - BMF --matrix (0/1), --rank - ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k - ConsecutiveOnesMatrixAugmentation --matrix (0/1), --bound - ConsecutiveOnesSubmatrix --matrix (0/1), --k - SparseMatrixCompression --matrix (0/1), --bound - MaximumLikelihoodRanking --matrix (i32 rows, semicolon-separated) - MinimumMatrixCover --matrix (i64 rows, semicolon-separated) - MinimumWeightDecoding --matrix (JSON 2D bool), --rhs (comma-separated booleans) - FeasibleBasisExtension --matrix (JSON 2D i64), --rhs, --required-columns - SteinerTree --graph, --edge-weights, --terminals - MultipleCopyFileAllocation --graph, --usage, --storage - AcyclicPartition --arcs [--weights] [--arc-weights] --weight-bound --cost-bound [--num-vertices] - CVP --basis, --target-vec [--bounds] - MultiprocessorScheduling --lengths, --num-processors, --deadline - SchedulingToMinimizeWeightedCompletionTime --lengths, --weights, --num-processors - SequencingWithinIntervals --release-times, --deadlines, --lengths - OptimalLinearArrangement --graph - RootedTreeArrangement --graph, --bound - MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k - MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-weights [--num-vertices] - RuralPostman (RPP) --graph, --edge-weights, --required-edges - StackerCrane --arcs, --graph, --arc-lengths, --edge-lengths [--num-vertices] - MultipleChoiceBranching --arcs [--weights] --partition --threshold [--num-vertices] - AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] - ConsistencyOfDatabaseFrequencyTables --num-objects, --attribute-domains, --frequency-tables [--known-values] - SubgraphIsomorphism --graph (host), --pattern (pattern) - GroupingBySwapping --string, --bound [--alphabet-size] - LCS --strings [--alphabet-size] - ClosestString --alphabet-size, --strings - ClosestSubstring --alphabet-size, --strings, --substring-length - FAS --arcs [--weights] [--num-vertices] - FVS --arcs [--weights] [--num-vertices] - QBF --num-vars, --clauses, --quantifiers - SteinerTreeInGraphs --graph, --edge-weights, --terminals - PartitionIntoPathsOfLength2 --graph - ResourceConstrainedScheduling --num-processors, --resource-bounds, --resource-requirements, --deadline - IntegerKnapsack --sizes, --values, --capacity - PartiallyOrderedKnapsack --sizes, --values, --capacity, --precedences - QAP --matrix (cost), --distance-matrix - StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] - JobShopScheduling --jobs [--num-processors] - FlowShopScheduling --task-lengths, --deadline [--num-processors] - StaffScheduling --schedules, --requirements, --num-workers, --k - TimetableDesign --num-periods, --num-craftsmen, --num-tasks, --craftsman-avail, --task-avail, --requirements - MinimumTardinessSequencing --num-tasks, --deadlines [--precedences] - RectilinearPictureCompression --matrix (0/1), --k - SchedulingWithIndividualDeadlines --num-tasks, --num-processors/--m, --deadlines [--precedences] - SequencingToMinimizeMaximumCumulativeCost --costs [--precedences] - SequencingToMinimizeTardyTaskWeight --lengths, --weights, --deadlines - SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedences] - SequencingToMinimizeWeightedTardiness --lengths, --weights, --deadlines, --bound - SequencingWithDeadlinesAndSetUpTimes --lengths, --deadlines, --compilers, --setup-times - MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] - MinimumInternalMacroDataCompression --string, --pointer-cost [--alphabet-size] - SCS --strings [--alphabet-size] - StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] - D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 - MinimumDummyActivitiesPert --arcs [--num-vertices] - FeasibleRegisterAssignment --arcs, --assignment, --k [--num-vertices] - MinimumFaultDetectionTestSet --arcs, --inputs, --outputs [--num-vertices] - MinimumWeightAndOrGraph --arcs, --source, --gate-types, --weights [--num-vertices] - MinimumCodeGenerationOneRegister --arcs [--num-vertices] - MinimumCodeGenerationParallelAssignments --num-variables, --assignments - MinimumCodeGenerationUnlimitedRegisters --left-arcs, --right-arcs [--num-vertices] - MinimumRegisterSufficiencyForLoops --loop-length, --loop-variables - RegisterSufficiency --arcs, --bound [--num-vertices] - CBQ --domain-size, --relations, --conjuncts-spec - IntegerExpressionMembership --expression (JSON), --target - MinimumGeometricConnectedDominatingSet --positions (float x,y pairs), --radius - MinimumDecisionTree --test-matrix (JSON 2D bool), --num-objects, --num-tests - MinimumDisjunctiveNormalForm (MinDNF) --num-vars, --truth-table - SquareTiling (WangTiling) --num-colors, --tiles, --grid-size - ILP, CircuitSAT (via reduction only) - -Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): - KingsSubgraph, TriangularSubgraph --positions (integer x,y pairs) - UnitDiskGraph --positions (float x,y pairs) [--radius] - -Random generation: - --random --num-vertices N [--edge-prob 0.5] [--seed 42] - -Examples: - pred create --example MIS/SimpleGraph/i32 - pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 - pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 --example-side target - pred create MIS --graph 0-1,1-2,2-3 --weights 1,1,1 - pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" - pred create NonTautology --num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\" - pred create QUBO --matrix \"1,0.5;0.5,2\" - pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12 - 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 - pred create GeneralizedHex --graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5 - 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 - pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10 - pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 | pred solve - --solver brute-force - pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force - pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" - pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 - pred create MIS --random --num-vertices 10 --edge-prob 0.3 - pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10 - pred create SchedulingToMinimizeWeightedCompletionTime --lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2 - pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3 - pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" - pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5 - pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 - pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6 - pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 - pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\" - pred create X3C --universe 9 --subsets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" - pred create SetBasis --universe 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3 - pred create MinimumCardinalityKey --num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\" - pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3 - pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"")] -pub struct CreateArgs { - /// Problem type (e.g., MIS, QUBO, SAT). Omit when using --example. - #[arg(value_parser = crate::problem_name::ProblemNameParser)] - pub problem: Option, - /// Build a problem from the canonical example database using a structural problem spec. - #[arg(long, value_parser = crate::problem_name::ProblemNameParser)] - pub example: Option, - /// Target problem spec for canonical rule example lookup. - #[arg(long = "to", value_parser = crate::problem_name::ProblemNameParser)] - pub example_target: Option, - /// Which side of a rule example to emit [default: source]. - #[arg(long, value_enum, default_value = "source")] - pub example_side: ExampleSide, - /// Graph edge list (e.g., 0-1,1-2,2-3) - #[arg(long)] - pub graph: Option, - /// Vertex weights (e.g., 1,1,1,1) [default: all 1s] - #[arg(long)] - pub weights: Option, - /// Edge weights (e.g., 2,3,1) [default: all 1s] - #[arg(long)] - pub edge_weights: Option, - /// Edge lengths (e.g., 2,3,1) [default: all 1s] - #[arg(long)] - pub edge_lengths: Option, - /// Capacities (edge capacities for flow problems, capacity levels for CapacityAssignment) - #[arg(long)] - pub capacities: Option, - /// Demands for ProductionPlanning (comma-separated, e.g., "5,3,7,2,8,5") - #[arg(long)] - pub demands: Option, - /// Setup costs for ProductionPlanning (comma-separated, e.g., "10,10,10,10,10,10") - #[arg(long)] - pub setup_costs: Option, - /// Per-unit production costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") - #[arg(long)] - pub production_costs: Option, - /// Per-unit inventory costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") - #[arg(long)] - pub inventory_costs: Option, - /// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1) - #[arg(long)] - pub bundle_capacities: Option, - /// Cost matrix for CapacityAssignment (semicolon-separated rows, e.g., "1,3,6;2,4,7") - #[arg(long)] - pub cost_matrix: Option, - /// Delay matrix for CapacityAssignment (semicolon-separated rows, e.g., "8,4,1;7,3,1") - #[arg(long)] - pub delay_matrix: Option, - /// Edge lower bounds for lower-bounded flow problems (e.g., 1,1,0,0,1,0,1) - #[arg(long)] - pub lower_bounds: Option, - /// Vertex multipliers in vertex order (e.g., 1,2,3,1) - #[arg(long)] - pub multipliers: Option, - /// Source vertex for path-based graph problems and MinimumCutIntoBoundedSets - #[arg(long)] - pub source: Option, - /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets - #[arg(long)] - pub sink: Option, - /// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, PathConstrainedNetworkFlow, and UndirectedFlowLowerBounds - #[arg(long)] - pub requirement: Option, - /// Required number of paths for LengthBoundedDisjointPaths - #[arg(long)] - pub num_paths_required: Option, - /// Prescribed directed s-t paths as semicolon-separated arc-index sequences (e.g., "0,2,5;1,4,6") - #[arg(long)] - pub paths: Option, - /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] - #[arg(long)] - pub couplings: Option, - /// On-site fields h_i for SpinGlass (e.g., 0,0,1) [default: all 0s] - #[arg(long)] - pub fields: Option, - /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") - #[arg(long)] - pub clauses: Option, - /// Disjuncts for NonTautology (semicolon-separated, e.g., "1,2;-1,3") - #[arg(long)] - pub disjuncts: Option, - /// Number of variables (for SAT/KSAT) - #[arg(long)] - pub num_vars: Option, - /// Matrix input. QUBO uses semicolon-separated numeric rows ("1,0.5;0.5,2"); - /// ConsecutiveBlockMinimization uses a JSON 2D bool array ('[[true,false],[false,true]]') - #[arg(long)] - pub matrix: Option, - /// Shared integer parameter (use `pred create ` for the problem-specific meaning) - #[arg(long)] - pub k: Option, - /// Number of partitions for GraphPartitioning (currently must be 2) - #[arg(long)] - pub num_partitions: Option, - /// Generate a random instance (graph-based problems only) - #[arg(long)] - pub random: bool, - /// Number of vertices for random graph generation - #[arg(long)] - pub num_vertices: Option, - /// Source vertex for path problems - #[arg(long)] - pub source_vertex: Option, - /// Target vertex for path problems - #[arg(long)] - pub target_vertex: Option, - /// Edge probability for random graph generation (0.0 to 1.0) [default: 0.5] - #[arg(long)] - pub edge_prob: Option, - /// Random seed for reproducibility - #[arg(long)] - pub seed: Option, - /// Target value (for Factoring, SubsetSum, and SubsetProduct) - #[arg(long)] - pub target: Option, - /// Bits for first factor (for Factoring); also accepted as a processor-count alias for scheduling create commands - #[arg(long)] - pub m: Option, - /// Bits for second factor (for Factoring) - #[arg(long)] - pub n: Option, - /// Vertex positions for geometry-based graphs (semicolon-separated x,y pairs, e.g., "0,0;1,0;1,1") - #[arg(long)] - pub positions: Option, - /// Radius for UnitDiskGraph [default: 1.0] - #[arg(long)] - pub radius: Option, - /// Source vertex s_1 for commodity 1 - #[arg(long)] - pub source_1: Option, - /// Sink vertex t_1 for commodity 1 - #[arg(long)] - pub sink_1: Option, - /// Source vertex s_2 for commodity 2 - #[arg(long)] - pub source_2: Option, - /// Sink vertex t_2 for commodity 2 - #[arg(long)] - pub sink_2: Option, - /// Required flow R_1 for commodity 1 - #[arg(long)] - pub requirement_1: Option, - /// Required flow R_2 for commodity 2 - #[arg(long)] - pub requirement_2: Option, - /// Item sizes for BinPacking (comma-separated, e.g., "3,3,2,2") - #[arg(long)] - pub sizes: Option, - /// Record access probabilities for ExpectedRetrievalCost (comma-separated, e.g., "0.2,0.15,0.15,0.2,0.1,0.2") - #[arg(long)] - pub probabilities: Option, - /// Link lengths for MinimumDiscretePlanarInverseKinematics (comma-separated positive reals, e.g., "2.0,1.0") - #[arg(long)] - pub link_lengths: Option, - /// Target point (x,y) for MinimumDiscretePlanarInverseKinematics (e.g., "2.0,1.0") - #[arg(long)] - pub target_point: Option, - /// Sampled absolute orientations per link for MinimumDiscretePlanarInverseKinematics (semicolon-separated angle lists, e.g., "0.0,1.5707963267948966;0.0,1.5707963267948966") - #[arg(long)] - pub orientation_samples: Option, - /// Admissible (a_{j-1}, a_j) pair sets per junction for MinimumDiscretePlanarInverseKinematics (pipe-separated junctions, each comma-separated "i-j" pairs, e.g., "0-0,0-1,1-1") - #[arg(long)] - pub allowed_pairs: Option, - /// Source labelled digraph G1 for MaximumCommonEdgeSubgraph. Format: ":,,..." with each arc "-

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) +} + +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() +} - Ok(Some((data, resolved_variant.clone()))) +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( @@ -221,211 +277,136 @@ pub(super) fn missing_schema_field_error( field_type: &str, is_geometry: bool, ) -> anyhow::Error { - let display = problem_help_flag_name(canonical, field_name, field_type, is_geometry); - let flags: Vec = display - .split('/') - .filter_map(|part| { - let trimmed = part.trim().trim_start_matches("--"); - (!trimmed.is_empty()).then(|| format!("--{trimmed}")) - }) - .collect(); - let requirement = match flags.as_slice() { - [] => format!("--{}", field_name.replace('_', "-")), - [flag] => flag.clone(), - [first, second] => format!("{first} or {second}"), - _ => { - let last = flags.last().cloned().unwrap_or_default(); - format!("{}, or {}", flags[..flags.len() - 1].join(", "), last) - } - }; + let 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.n.ok_or_else(|| { - anyhow::anyhow!("BoyceCoddNormalFormViolation requires --n, --sets, and --target") - })?; - Ok(serde_json::to_value(parse_bcnf_functional_deps( - raw, - num_attributes, - )?)?) - } - ("BoundedComponentSpanningForest", "max_weight") => { - let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --max-weight 6"; - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") - })?; - let max_weight = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_weight)) - } - ("ConsecutiveBlockMinimization", "matrix") => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("FeasibleBasisExtension", "matrix") => { - let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("IntegralFlowBundles", "bundle_capacities") => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - Ok(serde_json::to_value(parse_bundle_capacities( - args, - bundles.len(), - usage, - )?)?) - } - ("IntegralFlowHomologousArcs", "homologous_pairs") => { - Ok(serde_json::to_value(parse_homologous_pairs(args)?)?) - } - ("LengthBoundedDisjointPaths", "max_length") => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let max_length = usize::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_length)) - } - ("LongestCommonSubsequence", "strings") => { - let (strings, _) = parse_lcs_strings(raw)?; - Ok(serde_json::to_value(strings)?) - } - ("MinimumDecisionTree", "test_matrix") => { - let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumDecisionTree requires --test-matrix as a JSON 2D bool array\n\n{usage}\n\nFailed to parse --test-matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightDecoding", "matrix") => { - let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightSolutionToLinearEquations", "matrix") => { - let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("GroupingBySwapping", "string") - | ("StringToStringCorrection", "source") - | ("StringToStringCorrection", "target") => { - Ok(serde_json::to_value(parse_symbol_list_allow_empty(raw)?)?) + parse_field_value(concrete_type, field_name, raw, context) +} + +pub(crate) fn create_inputs_for( + canonical: &str, + resolved_variant: &BTreeMap, +) -> Vec { + 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(); + + 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, + ); } - ("MultipleCopyFileAllocation", "usage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.usage.as_deref(), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) + } 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(field.name, field.type_name, is_geometry); + insert_create_input( + &mut inputs, + &name, + input_value_kind(&concrete_type), + field.name, + ); + } + } } - ("MultipleCopyFileAllocation", "storage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.storage.as_deref(), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) + 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", + ); } - ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => { - Ok(serde_json::to_value(parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?)?) + if graph_type == "UnitDiskGraph" { + insert_create_input( + &mut inputs, + "radius", + InputValueKind::F64, + "unit-disk graph radius", + ); } - ("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, - )?)?) + } + if let Some(random) = variant_entry.random { + insert_create_input( + &mut inputs, + "random", + InputValueKind::Bool, + "random generation", + ); + for input in random.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, + ); } - _ => parse_field_value(concrete_type, field_name, raw, context), } -} -pub(super) fn schema_driven_supported_problem(canonical: &str) -> bool { - canonical != "ILP" && canonical != "CircuitSAT" + inputs + .into_iter() + .map(|(name, (kind, _))| CreateInput { name, kind }) + .collect() } -pub(super) fn schema_field_flag_keys( - canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> Vec { - let mut keys = vec![field_name.replace('_', "-")]; - for display_key in problem_help_flag_name(canonical, field_name, field_type, is_geometry) - .split('/') - .map(|key| key.trim().trim_start_matches("--").to_string()) - .filter(|key| !key.is_empty()) - { - if !keys.contains(&display_key) { - keys.push(display_key); - } +fn insert_create_input( + inputs: &mut BTreeMap, + name: &str, + kind: InputValueKind, + source: &str, +) { + if let Some((existing_kind, existing_source)) = inputs.get(name) { + assert_eq!( + *existing_kind, kind, + "create input --{name} has conflicting types from `{existing_source}` and `{source}`" + ); + return; } - keys + inputs.insert(name.to_string(), (kind, source.to_string())); } -pub(super) fn get_schema_flag_value( - flag_map: &std::collections::HashMap<&'static str, Option>, - keys: &[String], -) -> Option { - keys.iter() - .find_map(|key| flag_map.get(key.as_str()).cloned().flatten()) +fn input_value_kind(concrete_type: &str) -> InputValueKind { + match normalize_type_name(concrete_type).as_str() { + "usize" => InputValueKind::Usize, + "u64" => InputValueKind::U64, + "i32" => InputValueKind::I32, + "i64" => InputValueKind::I64, + "f64" => InputValueKind::F64, + "bool" => InputValueKind::Bool, + _ => InputValueKind::Text, + } } pub(super) fn resolve_schema_field_type( @@ -467,277 +448,15 @@ pub(super) fn seed_schema_context_from_cli( graph_type: &str, context: &mut CreateContext, ) -> Result<()> { - if let Some(num_vertices) = args.num_vertices { + if let Some(num_vertices) = args.value::("num-vertices") { context.seed_field("num_vertices", num_vertices)?; } if graph_type == "UnitDiskGraph" { - context.seed_field("radius", args.radius.unwrap_or(1.0))?; + context.seed_field("radius", args.value::("radius").unwrap_or(1.0))?; } Ok(()) } -pub(super) fn derive_schema_field_value( - args: &CreateArgs, - canonical: &str, - field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - if let Some(defaulted) = - derive_schema_default_value(canonical, field_name, concrete_type, context)? - { - return Ok(Some(defaulted)); - } - - if field_name == "graph" && concrete_type == "MixedGraph" { - let usage = format!( - "Usage: pred create {canonical} {}", - example_for(canonical, None) - ); - return Ok(Some(serde_json::to_value(parse_mixed_graph( - args, &usage, - )?)?)); - } - - if field_name == "graph" && concrete_type == "BipartiteGraph" { - let left = args - .left - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; - let right = args - .right - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; - let edges_raw = args - .biedges - .as_deref() - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --biedges"))?; - let edges = util::parse_edge_pairs(edges_raw)?; - validate_bipartite_edges(canonical, left, right, &edges)?; - return Ok(Some(serde_json::to_value(BipartiteGraph::new( - left, right, edges, - ))?)); - } - - if canonical == "ClosestVectorProblem" - && field_name == "bounds" - && normalize_type_name(concrete_type) == "Vec" - { - return Ok(Some(parse_cvp_bounds_value( - args.bounds.as_deref(), - context, - )?)); - } - - if canonical == "ConjunctiveBooleanQuery" - && field_name == "num_variables" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .conjuncts_spec - .as_deref() - .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec"))?; - return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); - } - - if canonical == "GroupingBySwapping" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .string - .as_deref() - .ok_or_else(|| anyhow::anyhow!("GroupingBySwapping requires --string"))?; - let string = parse_symbol_list_allow_empty(raw)?; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } - - if canonical == "JobShopScheduling" - && field_name == "num_processors" - && normalize_type_name(concrete_type) == "usize" - { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let inferred_processors = match args.job_tasks.as_deref() { - Some(job_tasks) => { - let jobs = parse_job_shop_jobs(job_tasks)?; - jobs.iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1) - } - None => None, - }; - let num_processors = - resolve_processor_count_flags("JobShopScheduling", usage, args.num_processors, args.m)? - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - return Ok(Some(serde_json::json!(num_processors))); - } - - if canonical == "LongestCommonSubsequence" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .strings - .as_deref() - .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; - let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred_alphabet_size)))); - } - - if canonical == "LongestCommonSubsequence" - && field_name == "max_length" - && normalize_type_name(concrete_type) == "usize" - { - let strings: Vec> = - serde_json::from_value(context.parsed_fields.get("strings").cloned().ok_or_else( - || anyhow::anyhow!("LCS max_length derivation requires parsed strings"), - )?)?; - let max_length = strings.iter().map(Vec::len).min().unwrap_or(0); - return Ok(Some(serde_json::json!(max_length))); - } - - if canonical == "QUBO" - && field_name == "num_vars" - && normalize_type_name(concrete_type) == "usize" - { - let matrix = parse_matrix(args)?; - return Ok(Some(serde_json::json!(matrix.len()))); - } - - if canonical == "StringToStringCorrection" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let source = parse_symbol_list_allow_empty(args.source_string.as_deref().unwrap_or(""))?; - let target = parse_symbol_list_allow_empty(args.target_string.as_deref().unwrap_or(""))?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } - - if field_name == "precedences" - && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" - && args.precedences.is_none() - && args.precedence_pairs.is_none() - { - return Ok(Some(serde_json::json!([]))); - } - - if canonical == "ComparativeContainment" - && matches!(field_name, "r_weights" | "s_weights") - && matches!( - normalize_type_name(concrete_type).as_str(), - "Vec" | "Vec" | "Vec" - ) - { - let sets_len = context - .parsed_fields - .get(match field_name { - "r_weights" => "r_sets", - _ => "s_sets", - }) - .and_then(serde_json::Value::as_array) - .map(Vec::len); - if let Some(len) = sets_len { - let value = match normalize_type_name(concrete_type).as_str() { - "Vec" | "Vec" => serde_json::json!(vec![1_i32; len]), - "Vec" => serde_json::json!(vec![1.0_f64; len]), - _ => unreachable!(), - }; - return Ok(Some(value)); - } - } - - if canonical == "ConsistencyOfDatabaseFrequencyTables" - && field_name == "known_values" - && normalize_type_name(concrete_type) == "Vec" - && args.known_values.is_none() - { - return Ok(Some(serde_json::json!([]))); - } - - if canonical == "LengthBoundedDisjointPaths" - && field_name == "max_paths" - && normalize_type_name(concrete_type) == "usize" - { - let graph_value = context.parsed_fields.get("graph").cloned(); - let source = context.usize_field("source"); - let sink = context.usize_field("sink"); - if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { - let graph: SimpleGraph = - serde_json::from_value(graph_value).context("Failed to deserialize graph")?; - let max_paths = graph - .neighbors(source) - .len() - .min(graph.neighbors(sink).len()); - return Ok(Some(serde_json::json!(max_paths))); - } - } - - Ok(None) -} - -pub(super) fn derive_schema_default_value( - canonical: &str, - field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - let normalized = normalize_type_name(concrete_type); - - let one_list = |len: usize| match normalized.as_str() { - "Vec" | "Vec" => Some(serde_json::json!(vec![1_i32; len])), - "Vec" => Some(serde_json::json!(vec![1_u64; len])), - "Vec" => Some(serde_json::json!(vec![1_i64; len])), - "Vec" => Some(serde_json::json!(vec![1_usize; len])), - "Vec" => Some(serde_json::json!(vec![1.0_f64; len])), - _ => None, - }; - - let derived = match field_name { - "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), - "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), - "arc_weights" | "arc_lengths" if context.num_arcs.is_some() => { - context.num_arcs.and_then(one_list) - } - "capacities" if canonical == "PathConstrainedNetworkFlow" => { - context.num_arcs.and_then(one_list) - } - "couplings" if canonical == "SpinGlass" => context.num_edges.and_then(one_list), - "fields" if canonical == "SpinGlass" => match normalized.as_str() { - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0_i32; len])), - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0.0_f64; len])), - _ => None, - }, - _ => None, - }; - - Ok(derived) -} - -pub(super) fn schema_field_requires_derived_input(field_name: &str, concrete_type: &str) -> bool { - field_name == "graph" && matches!(concrete_type, "MixedGraph" | "BipartiteGraph") -} - pub(super) fn with_schema_usage( error: anyhow::Error, canonical: &str, @@ -747,11 +466,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}",) +} + +pub(super) 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( @@ -802,6 +548,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" => { @@ -993,31 +740,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") @@ -1245,91 +967,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, @@ -1580,1116 +1217,20 @@ pub(super) fn parse_unit_disk_graph_value( Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) } -pub(super) fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { - match type_name { - "SimpleGraph" => "edge list: 0-1,1-2,2-3", - "G" => match graph_type { - Some("KingsSubgraph" | "TriangularSubgraph") => "integer positions: \"0,0;1,0;1,1\"", - Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"", - _ => "edge list: 0-1,1-2,2-3", - }, - "Vec<(Vec, Vec)>" => "semicolon-separated dependencies: \"0,1>2;0,2>3\"", - "Vec" => "comma-separated integers: 4,5,3,2,6", - "Vec" => "comma-separated: 1,2,3", - "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", - "Vec" => "comma-separated indices: 0,2,4", - "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { - "comma-separated weighted edges: 0-2:3,1-3:5" - } - "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", - "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", - "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", - "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", - "usize" => "integer", - "u64" => "integer", - "i64" => "integer", - "BigUint" => "nonnegative decimal integer", - "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", - "Vec" => "comma-separated integers: 3,7,1,8", - "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", - "LabelledDigraph" => { - "labelled digraph \":-

+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/formula/circuit.rs b/src/models/formula/circuit.rs index 1a951265c..65905e22a 100644 --- a/src/models/formula/circuit.rs +++ b/src/models/formula/circuit.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Circuit SAT", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Find satisfying input to a boolean circuit", fields: &[ diff --git a/src/models/formula/ksat.rs b/src/models/formula/ksat.rs index e53d094de..23e185bd8 100644 --- a/src/models/formula/ksat.rs +++ b/src/models/formula/ksat.rs @@ -54,6 +54,7 @@ inventory::submit! { display_name: "K-Satisfiability", aliases: &["KSAT"], dimensions: &[VariantDimension::new("k", "KN", &["KN", "K2", "K3"])], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "SAT with exactly k literals per clause", fields: &[ diff --git a/src/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index ee6f83fd8..ca415b878 100644 --- a/src/models/formula/maximum_2_satisfiability.rs +++ b/src/models/formula/maximum_2_satisfiability.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Maximum 2-Satisfiability", aliases: &["MAX2SAT"], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Maximize the number of satisfied 2-literal clauses", fields: &[ diff --git a/src/models/formula/nae_satisfiability.rs b/src/models/formula/nae_satisfiability.rs index 834b9a4e7..6874d5c93 100644 --- a/src/models/formula/nae_satisfiability.rs +++ b/src/models/formula/nae_satisfiability.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Not-All-Equal Satisfiability", aliases: &["NAESAT"], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Find an assignment where every CNF clause has both a true and a false literal", fields: &[ diff --git a/src/models/formula/non_tautology.rs b/src/models/formula/non_tautology.rs index 7e983cfb8..941149d10 100644 --- a/src/models/formula/non_tautology.rs +++ b/src/models/formula/non_tautology.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Non-Tautology", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Find a falsifying assignment for a DNF formula (proving it is not a tautology)", fields: &[ diff --git a/src/models/formula/one_in_three_satisfiability.rs b/src/models/formula/one_in_three_satisfiability.rs index 6a6c87597..2d320ba8f 100644 --- a/src/models/formula/one_in_three_satisfiability.rs +++ b/src/models/formula/one_in_three_satisfiability.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "One-in-Three Satisfiability", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "3-SAT variant where each clause has exactly one true literal", fields: &[ diff --git a/src/models/formula/planar_3_satisfiability.rs b/src/models/formula/planar_3_satisfiability.rs index 6162c19bf..0f5e51c57 100644 --- a/src/models/formula/planar_3_satisfiability.rs +++ b/src/models/formula/planar_3_satisfiability.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Planar 3-Satisfiability", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "3-SAT with planar variable-clause incidence graph", fields: &[ diff --git a/src/models/formula/qbf.rs b/src/models/formula/qbf.rs index c47b88bcc..99a8e76f7 100644 --- a/src/models/formula/qbf.rs +++ b/src/models/formula/qbf.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Quantified Boolean Formulas", aliases: &["QBF"], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Determine if a quantified Boolean formula is true", fields: &[ diff --git a/src/models/formula/sat.rs b/src/models/formula/sat.rs index 0557598a2..920660ad1 100644 --- a/src/models/formula/sat.rs +++ b/src/models/formula/sat.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Satisfiability", aliases: &["SAT"], dimensions: &[], + category: crate::registry::ProblemCategory::Formula, module_path: module_path!(), description: "Find satisfying assignment for CNF formula", fields: &[ diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 4bb5f4935..1510dedfc 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; @@ -21,15 +21,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +45,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 +294,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..6609764f4 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}; @@ -10,12 +10,10 @@ inventory::submit! { display_name: "Balanced Complete Bipartite Subgraph", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +26,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 +180,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..69b5b6a95 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; @@ -26,14 +26,10 @@ inventory::submit! { display_name: "Biclique Cover", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +66,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 +323,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..70e084a22 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; @@ -21,13 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +51,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 +311,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..ea0b841bc 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; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Bottleneck Traveling Salesman", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +29,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 { @@ -156,8 +210,18 @@ pub(crate) fn canonical_model_example_specs() -> Vec "num_vertices^2 * 2^num_vertices", + default BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 32f229a1a..68dc4e49d 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; @@ -21,14 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +46,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 +264,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..16b580dc4 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; @@ -22,14 +22,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +76,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 +348,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/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs index 47338a8f1..e17ac6954 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does G have a spanning tree with maximum vertex degree at most K?", fields: &[ diff --git a/src/models/graph/directed_hamiltonian_path.rs b/src/models/graph/directed_hamiltonian_path.rs index b395853cc..6dd2d6128 100644 --- a/src/models/graph/directed_hamiltonian_path.rs +++ b/src/models/graph/directed_hamiltonian_path.rs @@ -16,6 +16,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed graph contain a Hamiltonian path?", fields: &[ diff --git a/src/models/graph/directed_two_commodity_integral_flow.rs b/src/models/graph/directed_two_commodity_integral_flow.rs index f67445df5..9a1d18b92 100644 --- a/src/models/graph/directed_two_commodity_integral_flow.rs +++ b/src/models/graph/directed_two_commodity_integral_flow.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Directed Two-Commodity Integral Flow", aliases: &["D2CIF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Two-commodity integral flow feasibility on a directed graph", fields: &[ diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index d565fa123..92599eb1e 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; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +37,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 +297,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/eulerian_path.rs b/src/models/graph/eulerian_path.rs index b45f43db8..8f29e4261 100644 --- a/src/models/graph/eulerian_path.rs +++ b/src/models/graph/eulerian_path.rs @@ -28,6 +28,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed multigraph admit a directed trail using every arc exactly once?", fields: &[ diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index ef7252aae..0e44aef52 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; @@ -20,13 +20,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +40,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, @@ -263,8 +294,17 @@ where } } +crate::impl_random_generate!( + GeneralizedHex, + crate::random::EndpointRandomSpec, + |spec| { + let (source, sink) = spec.endpoints()?; + Ok(GeneralizedHex::new(spec.graph()?, source, sink)) + } +); + crate::declare_variants! { - default GeneralizedHex => "3^num_playable_vertices", + default GeneralizedHex => "3^num_playable_vertices" create GeneralizedHexCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index f69aadd2b..8901f07df 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum cut balanced bisection of a graph", fields: &[ diff --git a/src/models/graph/hamiltonian_circuit.rs b/src/models/graph/hamiltonian_circuit.rs index 471c15af0..7617b761c 100644 --- a/src/models/graph/hamiltonian_circuit.rs +++ b/src/models/graph/hamiltonian_circuit.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the graph contain a Hamiltonian circuit?", fields: &[ @@ -164,8 +165,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(HamiltonianCircuit::new(spec.graph()?)) } +); + crate::declare_variants! { - default HamiltonianCircuit => "1.657^num_vertices", + default HamiltonianCircuit => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/hamiltonian_path.rs b/src/models/graph/hamiltonian_path.rs index ddc39ffa1..fc324b7d9 100644 --- a/src/models/graph/hamiltonian_path.rs +++ b/src/models/graph/hamiltonian_path.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a Hamiltonian path in a graph", fields: &[ @@ -167,8 +168,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(HamiltonianPath::new(spec.graph()?)) } +); + crate::declare_variants! { - default HamiltonianPath => "1.657^num_vertices", + default HamiltonianPath => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index 08dfe8408..42b1ba45a 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a Hamiltonian path between two specified vertices in a graph", fields: &[ @@ -75,6 +76,20 @@ pub struct HamiltonianPathBetweenTwoVertices { target_vertex: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct HamiltonianPathBetweenTwoVerticesRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Path start vertex (default: 0). + source_vertex: Option, + /// Path end vertex (default: the final vertex). + target_vertex: Option, +} + impl HamiltonianPathBetweenTwoVertices { /// Create a new Hamiltonian Path Between Two Vertices problem. /// @@ -229,8 +244,32 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + HamiltonianPathBetweenTwoVerticesRandomSpec, + |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let source = spec.source_vertex.unwrap_or(0); + let sink = spec.target_vertex.unwrap_or(spec.num_vertices - 1); + if source >= spec.num_vertices || sink >= spec.num_vertices || source == sink { + return Err( + "source_vertex and target_vertex must be distinct valid vertices".to_string(), + ); + } + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + Ok(HamiltonianPathBetweenTwoVertices::new(graph, source, sink)) + } +); + crate::declare_variants! { - default HamiltonianPathBetweenTwoVertices => "1.657^num_vertices", + default HamiltonianPathBetweenTwoVertices => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/highly_connected_deletion.rs b/src/models/graph/highly_connected_deletion.rs index a932f3c81..53c4d92a8 100644 --- a/src/models/graph/highly_connected_deletion.rs +++ b/src/models/graph/highly_connected_deletion.rs @@ -32,6 +32,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Minimum number of edge deletions so every component is an isolated vertex or a highly connected graph on >=3 vertices", fields: &[ diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index 893cb1da3..935c2076c 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}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow with Bundles", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +40,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 +347,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..c54f7ea72 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}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow with Homologous Arcs", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +45,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 +266,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..7fda1c4a8 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}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow With Multipliers", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +39,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 +277,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/isomorphic_spanning_tree.rs b/src/models/graph/isomorphic_spanning_tree.rs index b624280e7..3bb981c61 100644 --- a/src/models/graph/isomorphic_spanning_tree.rs +++ b/src/models/graph/isomorphic_spanning_tree.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does graph G contain a spanning tree isomorphic to tree T?", fields: &[ diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 94fa7e788..24d99b665 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}; @@ -14,12 +14,10 @@ inventory::submit! { display_name: "k-Clique", aliases: &["Clique"], dimensions: &[VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"])], + category: crate::registry::ProblemCategory::Graph, 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 +32,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 { @@ -135,8 +177,22 @@ fn is_kclique_config(graph: &G, config: &[usize], k: usize) -> bool { true } +crate::impl_random_generate!( + KClique, + crate::random::CliqueRandomSpec, + |spec| { + if spec.k == 0 || spec.k > spec.num_vertices { + return Err(format!( + "k must be between 1 and num_vertices ({})", + spec.num_vertices + )); + } + Ok(KClique::new(spec.graph()?, spec.k)) + } +); + crate::declare_variants! { - default KClique => "1.1996^num_vertices", + default KClique => "1.1996^num_vertices" create KCliqueCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9e810dfc9..d1fa16fde 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}; @@ -18,11 +18,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("k", "KN", &["KN", "K2", "K3", "K4", "K5"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +67,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) } @@ -200,13 +274,37 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::ColoringRandomSpec, |spec| { + let k = spec.k.unwrap_or(3); + if k == 0 { + return Err("k must be positive".to_string()); + } + Ok(KColoring::with_k(spec.graph()?, k)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 2) { return Err("k must match the selected K2 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 3) { return Err("k must match the selected K3 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 4) { return Err("k must match the selected K4 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 5) { return Err("k must match the selected K5 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); + crate::declare_variants! { - default KColoring => "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 random, + KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec random, + KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec random, + KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec random, // Best known: O*((2-ε)^n) for some ε > 0 (Zamir 2021), concrete ε unknown - KColoring => "2^num_vertices", + KColoring => "2^num_vertices" create FixedKColoringCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/kernel.rs b/src/models/graph/kernel.rs index 72b3e1b52..d9dc901a4 100644 --- a/src/models/graph/kernel.rs +++ b/src/models/graph/kernel.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed graph contain a kernel (independent and absorbing vertex subset)?", fields: &[ diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index f26c26fcb..51f6204bc 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; @@ -17,14 +17,10 @@ inventory::submit! { display_name: "Kth Best Spanning Tree", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + category: crate::registry::ProblemCategory::Graph, 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 +42,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 +295,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..7d4e7a366 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; @@ -18,15 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +43,88 @@ 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, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LengthBoundedDisjointPathsRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Source vertex (default: 0). + source: Option, + /// Sink vertex (default: the final vertex). + sink: Option, + /// Maximum path length (default: num_vertices - 1). + max_length: Option, +} + +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. /// @@ -300,8 +377,33 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + LengthBoundedDisjointPathsRandomSpec, + |spec| { + let endpoints = crate::random::EndpointRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + source: spec.source, + sink: spec.sink, + }; + let (source, sink) = endpoints.endpoints()?; + let max_length = spec.max_length.unwrap_or(spec.num_vertices - 1); + if max_length == 0 { + return Err("max_length must be positive".to_string()); + } + Ok(LengthBoundedDisjointPaths::new( + endpoints.graph()?, + source, + sink, + max_length, + )) + } +); + crate::declare_variants! { - default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)", + default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)" create LengthBoundedDisjointPathsCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 735d11d00..16d330f76 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}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +46,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. /// @@ -255,8 +312,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let lengths = vec![1; graph.num_edges()]; + Ok(LongestCircuit::new(graph, lengths)) +}); + crate::declare_variants! { - default LongestCircuit => "2^num_vertices * num_vertices^2", + default LongestCircuit => "2^num_vertices * num_vertices^2" create LongestCircuitCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index fd70dbeb3..86e2292aa 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}; @@ -20,14 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +49,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 +306,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..6df88f83a 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}; @@ -14,17 +14,15 @@ inventory::submit! { ProblemSchemaEntry { name: "MaxCut", display_name: "Max Cut", - aliases: &["GraphPartitioning", "MaximumBipartiteSubgraph"], + aliases: &["MaximumBipartiteSubgraph"], dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +75,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. /// @@ -208,9 +267,15 @@ where total } +crate::impl_random_generate!(MaxCut, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(MaxCut::new(graph, weights)) +}); + 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 random, + 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..1c750f1fb 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}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +61,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 { @@ -222,8 +242,12 @@ pub(crate) fn is_maximal_independent_set(graph: &G, selected: &[bool]) true } +crate::impl_random_generate!(MaximalIS, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximalIS::new(spec.graph()?, vec![1; spec.num_vertices])) +}); + crate::declare_variants! { - default MaximalIS => "3^(num_vertices / 3)", + default MaximalIS => "3^(num_vertices / 3)" create MaximalISCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/maximum_achromatic_number.rs b/src/models/graph/maximum_achromatic_number.rs index b45aa0df2..de91a7b58 100644 --- a/src/models/graph/maximum_achromatic_number.rs +++ b/src/models/graph/maximum_achromatic_number.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a complete proper coloring maximizing the number of colors", fields: &[ @@ -155,8 +156,14 @@ where } } +crate::impl_random_generate!( + MaximumAchromaticNumber, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MaximumAchromaticNumber::new(spec.graph()?)) } +); + crate::declare_variants! { - default MaximumAchromaticNumber => "num_vertices^num_vertices", + default MaximumAchromaticNumber => "num_vertices^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 849bef38a..b7dd79e9c 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}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "One", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +64,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 { @@ -164,9 +184,16 @@ fn is_clique_config(graph: &G, config: &[usize]) -> bool { true } +crate::impl_random_generate!(MaximumClique, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumClique::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumClique, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumClique::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - MaximumClique => "1.1996^num_vertices", - default MaximumClique => "1.1996^num_vertices", + MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec random, + default MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec random, } #[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..969934cd7 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}; @@ -26,13 +26,10 @@ inventory::submit! { VariantDimension::new("weight", "One", &["One", "i32"]), VariantDimension::new("k", "KN", &["KN"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +88,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 +251,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_common_edge_subgraph.rs b/src/models/graph/maximum_common_edge_subgraph.rs index d35668c64..8a6577b9b 100644 --- a/src/models/graph/maximum_common_edge_subgraph.rs +++ b/src/models/graph/maximum_common_edge_subgraph.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Maximum Common Edge Subgraph", aliases: &["MCES"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Maximize the number of preserved labelled directed arcs under a partial injective vertex map from G1 into G2", fields: &[ diff --git a/src/models/graph/maximum_contact_map_overlap.rs b/src/models/graph/maximum_contact_map_overlap.rs index a325a83bb..d331c4744 100644 --- a/src/models/graph/maximum_contact_map_overlap.rs +++ b/src/models/graph/maximum_contact_map_overlap.rs @@ -26,6 +26,7 @@ inventory::submit! { display_name: "Maximum Contact Map Overlap", aliases: &["CMO", "MaxCMO"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Maximize the number of preserved contacts under an order-preserving partial injective alignment from G_1 into G_2", fields: &[ diff --git a/src/models/graph/maximum_domatic_number.rs b/src/models/graph/maximum_domatic_number.rs index 1052f6163..185b16b4a 100644 --- a/src/models/graph/maximum_domatic_number.rs +++ b/src/models/graph/maximum_domatic_number.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum number of disjoint dominating sets partitioning V", fields: &[ @@ -154,8 +155,14 @@ where } } +crate::impl_random_generate!( + MaximumDomaticNumber, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MaximumDomaticNumber::new(spec.graph()?)) } +); + crate::declare_variants! { - default MaximumDomaticNumber => "2.695^num_vertices", + default MaximumDomaticNumber => "2.695^num_vertices" random, } #[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..74ab09b60 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}; @@ -24,13 +24,10 @@ inventory::submit! { display_name: "Maximum Edge-Weighted k-Clique", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], + category: crate::registry::ProblemCategory::Graph, 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 +74,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 +220,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..f3e6d047b 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}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph", "KingsSubgraph", "TriangularSubgraph", "UnitDiskGraph"]), VariantDimension::new("weight", "One", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +64,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 { @@ -153,14 +283,36 @@ fn is_independent_set_config(graph: &G, config: &[usize]) -> bool { true } +crate::impl_random_generate!(MaximumIndependentSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(spec.graph()?, vec![One; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(KingsSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(KingsSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![One; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(TriangularSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::UnitDiskRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(UnitDiskGraph::new(crate::random::create_random_float_positions(spec.num_vertices, spec.seed), spec.radius.unwrap_or(1.0)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::UnitDiskRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(UnitDiskGraph::new(crate::random::create_random_float_positions(spec.num_vertices, spec.seed), spec.radius.unwrap_or(1.0)), vec![One; spec.num_vertices])) +}); + 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 random, + default MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleOneCreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsOneCreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetTriangularI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskOneCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MaximumIndependentSet diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 7fbf46b8c..475808b04 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find spanning tree maximizing the number of leaves", fields: &[ @@ -163,8 +164,19 @@ where } } +crate::impl_random_generate!( + MaximumLeafSpanningTree, + crate::random::SimpleGraphRandomSpec, + |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + Ok(MaximumLeafSpanningTree::new(spec.graph()?)) + } +); + crate::declare_variants! { - default MaximumLeafSpanningTree => "1.8966^num_vertices", + default MaximumLeafSpanningTree => "1.8966^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index f2c3d0719..f6137ca55 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}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +64,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. /// @@ -213,8 +267,14 @@ where } } +crate::impl_random_generate!(MaximumMatching, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(MaximumMatching::new(graph, weights)) +}); + crate::declare_variants! { - default MaximumMatching => "num_vertices^3", + default MaximumMatching => "num_vertices^3" create MaximumMatchingCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 5bf60ef53..52f002e28 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}; @@ -19,14 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +65,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 +358,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..2b008f232 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}; @@ -22,15 +22,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +62,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 +367,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_cost_circulation.rs b/src/models/graph/minimum_cost_circulation.rs index 9a405aab3..f9471cb35 100644 --- a/src/models/graph/minimum_cost_circulation.rs +++ b/src/models/graph/minimum_cost_circulation.rs @@ -43,6 +43,7 @@ inventory::submit! { display_name: "Minimum-Cost Circulation", aliases: &["MCC"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral circulation on a directed multigraph minimizing total signed arc cost", fields: &[ diff --git a/src/models/graph/minimum_cost_maximum_flow.rs b/src/models/graph/minimum_cost_maximum_flow.rs index 8065983f0..852a310eb 100644 --- a/src/models/graph/minimum_cost_maximum_flow.rs +++ b/src/models/graph/minimum_cost_maximum_flow.rs @@ -51,6 +51,7 @@ inventory::submit! { display_name: "Minimum-Cost Maximum-Flow", aliases: &["MCMF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow that lexicographically maximizes value then minimizes total arc cost", fields: &[ diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 55080af3c..db4e9dab7 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum number of cliques covering all edges", fields: &[ @@ -153,8 +154,14 @@ where } } +crate::impl_random_generate!( + MinimumCoveringByCliques, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumCoveringByCliques::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumCoveringByCliques => "2^num_edges", + default MinimumCoveringByCliques => "2^num_edges" random, } #[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..f13bf6991 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}; @@ -20,15 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +70,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. /// @@ -227,8 +260,15 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::EndpointRandomSpec, |spec| { + let (source, sink) = spec.endpoints()?; + let graph = spec.graph()?; + let edge_weights = vec![1; graph.num_edges()]; + Ok(MinimumCutIntoBoundedSets::new(graph, edge_weights, source, sink, spec.num_vertices)) +}); + crate::declare_variants! { - default MinimumCutIntoBoundedSets => "2^num_vertices", + default MinimumCutIntoBoundedSets => "2^num_vertices" create MinimumCutIntoBoundedSetsCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index fbedd5e05..932cefcbd 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}; @@ -21,12 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +60,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 { @@ -164,9 +186,16 @@ where } } +crate::impl_random_generate!(MinimumDominatingSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumDominatingSet::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MinimumDominatingSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumDominatingSet::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - default MinimumDominatingSet => "1.4969^num_vertices", - MinimumDominatingSet => "1.4969^num_vertices", + default MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, + MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet @@ -218,6 +247,7 @@ crate::register_decision_variant!( "1.4969^num_vertices", &[], "Decision version: does a dominating set of cost <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), @@ -245,6 +275,15 @@ 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())) + }, + random: None, 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..10dc3a5e3 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; @@ -20,15 +20,10 @@ inventory::submit! { display_name: "Minimum Dummy Activities in PERT Networks", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +41,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 +223,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_edge_cost_flow.rs b/src/models/graph/minimum_edge_cost_flow.rs index 86edf9980..fd0edc752 100644 --- a/src/models/graph/minimum_edge_cost_flow.rs +++ b/src/models/graph/minimum_edge_cost_flow.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Minimum Edge-Cost Flow", aliases: &["MECF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow minimizing the number of arcs with nonzero flow (weighted by price)", fields: &[ diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index a69fa1aa6..cefc8dcf1 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}; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +63,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 +185,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..3b03e69cc 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}; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +57,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 +173,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_geometric_connected_dominating_set.rs b/src/models/graph/minimum_geometric_connected_dominating_set.rs index b295af09e..3d79d415f 100644 --- a/src/models/graph/minimum_geometric_connected_dominating_set.rs +++ b/src/models/graph/minimum_geometric_connected_dominating_set.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Minimum Geometric Connected Dominating Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum connected dominating set in a geometric point set", fields: &[ diff --git a/src/models/graph/minimum_graph_bandwidth.rs b/src/models/graph/minimum_graph_bandwidth.rs index aac0cbce3..227682249 100644 --- a/src/models/graph/minimum_graph_bandwidth.rs +++ b/src/models/graph/minimum_graph_bandwidth.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a vertex ordering minimizing the maximum edge stretch", fields: &[ diff --git a/src/models/graph/minimum_intersection_graph_basis.rs b/src/models/graph/minimum_intersection_graph_basis.rs index d78795962..f4485d4a5 100644 --- a/src/models/graph/minimum_intersection_graph_basis.rs +++ b/src/models/graph/minimum_intersection_graph_basis.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum universe size for intersection graph representation", fields: &[ @@ -156,8 +157,14 @@ where } } +crate::impl_random_generate!( + MinimumIntersectionGraphBasis, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumIntersectionGraphBasis::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumIntersectionGraphBasis => "num_edges^num_edges", + default MinimumIntersectionGraphBasis => "num_edges^num_edges" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_maximal_matching.rs b/src/models/graph/minimum_maximal_matching.rs index a31b886c1..6e195a381 100644 --- a/src/models/graph/minimum_maximal_matching.rs +++ b/src/models/graph/minimum_maximal_matching.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph", "BipartiteGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a minimum-size matching that cannot be extended", fields: &[ @@ -145,8 +146,14 @@ where } } +crate::impl_random_generate!( + MinimumMaximalMatching, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumMaximalMatching::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumMaximalMatching => "1.3160^num_vertices", + default MinimumMaximalMatching => "1.3160^num_vertices" random, MinimumMaximalMatching => "1.3160^num_vertices", } diff --git a/src/models/graph/minimum_metric_dimension.rs b/src/models/graph/minimum_metric_dimension.rs index 21299860d..3414349bc 100644 --- a/src/models/graph/minimum_metric_dimension.rs +++ b/src/models/graph/minimum_metric_dimension.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum resolving set of a graph", fields: &[ diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 010a27bb7..8143937b8 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}; @@ -20,13 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +49,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 +228,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..fb98566b2 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}; @@ -19,14 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +66,88 @@ 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, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSumMulticenterRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Number of centers (default: max(1, num_vertices / 3)). + k: Option, +} + +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. /// @@ -247,8 +325,22 @@ where } } +crate::impl_random_generate!(MinimumSumMulticenter, MinimumSumMulticenterRandomSpec, |spec| { + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + }.graph()?; + let k = spec.k.unwrap_or(std::cmp::max(1, spec.num_vertices / 3)); + if k == 0 || k > spec.num_vertices { + return Err(format!("k must be between 1 and {}", spec.num_vertices)); + } + let lengths = vec![1; graph.num_edges()]; + Ok(MinimumSumMulticenter::new(graph, vec![1; spec.num_vertices], lengths, k)) +}); + crate::declare_variants! { - default MinimumSumMulticenter => "2^num_vertices", + default MinimumSumMulticenter => "2^num_vertices" create MinimumSumMulticenterCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index f33b93134..82c5b2aa0 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}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +60,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 { @@ -151,9 +176,16 @@ pub(crate) fn is_vertex_cover_config(graph: &G, config: &[usize]) -> b true } +crate::impl_random_generate!(MinimumVertexCover, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumVertexCover::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MinimumVertexCover, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumVertexCover::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - default MinimumVertexCover => "1.1996^num_vertices", - MinimumVertexCover => "1.1996^num_vertices", + default MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, + MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover @@ -182,12 +214,45 @@ impl Decision> { } } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct DecisionMinimumVertexCoverRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Maximum allowed cover cost. + bound: i64, +} + +crate::impl_random_generate!( + Decision>, + DecisionMinimumVertexCoverRandomSpec, + |spec| { + if spec.bound < 0 { + return Err("bound must be nonnegative".to_string()); + } + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + Ok(Decision::new( + MinimumVertexCover::new(graph, vec![1; spec.num_vertices]), + spec.bound, + )) + } +); + crate::register_decision_variant!( MinimumVertexCover, "DecisionMinimumVertexCover", "1.1996^num_vertices", &["DMVC", "VC", "VertexCover"], "Decision version: does a vertex cover of cost <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), @@ -195,9 +260,10 @@ crate::register_decision_variant!( 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", type_name: "i32", description: "Decision bound (maximum allowed cover cost)" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Decision bound (maximum allowed cover cost)" }, ], - size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)] + size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)], + random ); #[cfg(feature = "example-db")] diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index af333f700..a0d067bf4 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}; @@ -22,13 +22,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +42,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 +125,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 +312,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/monochromatic_triangle.rs b/src/models/graph/monochromatic_triangle.rs index 17366640b..ae7746a53 100644 --- a/src/models/graph/monochromatic_triangle.rs +++ b/src/models/graph/monochromatic_triangle.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "2-color edges so that no triangle is monochromatic", fields: &[ diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index c0e90cdba..e334347ce 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; @@ -20,14 +20,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +44,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 +340,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..433f7d144 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; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Multiple Copy File Allocation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +46,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 +250,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/optimal_linear_arrangement.rs b/src/models/graph/optimal_linear_arrangement.rs index d2f14f2f7..ac89218c5 100644 --- a/src/models/graph/optimal_linear_arrangement.rs +++ b/src/models/graph/optimal_linear_arrangement.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a vertex ordering on a line minimizing total edge length", fields: &[ @@ -153,8 +154,14 @@ where } } +crate::impl_random_generate!( + OptimalLinearArrangement, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(OptimalLinearArrangement::new(spec.graph()?)) } +); + crate::declare_variants! { - default OptimalLinearArrangement => "2^num_vertices", + default OptimalLinearArrangement => "2^num_vertices" random, } impl crate::models::decision::DecisionProblemMeta for OptimalLinearArrangement @@ -187,6 +194,7 @@ crate::register_decision_variant!( "2^num_vertices", &["DOLA"], "Decision version: does a linear arrangement of total edge length <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], diff --git a/src/models/graph/partial_feedback_edge_set.rs b/src/models/graph/partial_feedback_edge_set.rs index 5806cd534..f84035803 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}; @@ -18,13 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +43,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 +256,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/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 4189399be..869872aaf 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K groups each inducing a clique", fields: &[ diff --git a/src/models/graph/partition_into_forests.rs b/src/models/graph/partition_into_forests.rs index 4c82a565b..98f783358 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K classes each inducing an acyclic subgraph", fields: &[ diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index 717bcab97..e97d43046 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into triples each inducing at least two edges (P3 or triangle)", fields: &[ diff --git a/src/models/graph/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index 89fcef0bc..c6944c489 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K groups each inducing a perfect matching", fields: &[ diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index b14d5efe5..02148b0c5 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into triangles (K3 subgraphs)", fields: &[ diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index 373598e22..8bc785b16 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}; @@ -18,16 +18,10 @@ inventory::submit! { display_name: "Path-Constrained Network Flow", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +43,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 +118,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 +179,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 +307,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..412b1f051 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}; @@ -42,15 +42,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "f64"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +105,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 +384,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/rooted_tree_arrangement.rs b/src/models/graph/rooted_tree_arrangement.rs index 6fc20b366..59be6969f 100644 --- a/src/models/graph/rooted_tree_arrangement.rs +++ b/src/models/graph/rooted_tree_arrangement.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a rooted-tree embedding of a graph with bounded total edge stretch", fields: &[ @@ -34,6 +35,18 @@ pub struct RootedTreeArrangement { bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RootedTreeArrangementRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Maximum total edge stretch (defaults to a graph-size upper bound). + bound: Option, +} + #[derive(Debug, Clone)] struct TreeInfo { depth: Vec, @@ -204,8 +217,25 @@ fn are_ancestor_comparable(parent: &[usize], u: usize, v: usize) -> bool { is_ancestor(parent, u, v) || is_ancestor(parent, v, u) } +crate::impl_random_generate!( + RootedTreeArrangement, + RootedTreeArrangementRandomSpec, + |spec| { + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + let bound = spec + .bound + .unwrap_or_else(|| spec.num_vertices.saturating_sub(1) * graph.num_edges()); + Ok(RootedTreeArrangement::new(graph, bound)) + } +); + crate::declare_variants! { - default RootedTreeArrangement => "2^num_vertices", + default RootedTreeArrangement => "2^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 164eccdc0..8743ffe05 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}; @@ -20,13 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +62,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 +328,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..9518f1e25 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}; @@ -21,16 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +68,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 +410,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..9349e830a 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}; @@ -17,13 +17,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "f64"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +70,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. /// @@ -236,9 +299,15 @@ where } } +crate::impl_random_generate!(SpinGlass, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let num_edges = graph.num_edges(); + Ok(SpinGlass::from_graph(graph, vec![1; num_edges], vec![0; spec.num_vertices])) +}); + crate::declare_variants! { - default SpinGlass => "2^num_spins", - SpinGlass => "2^num_spins", + default SpinGlass => "2^num_spins" create SpinGlassI32CreateSpec random, + 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..5a805e042 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}, @@ -24,13 +24,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +61,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 { @@ -247,9 +287,25 @@ where } } +crate::impl_random_generate!(SteinerTree, crate::random::SimpleGraphRandomSpec, |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let mut state = crate::random::lcg_init(spec.seed); + let graph = spec.graph()?; + for _ in 0..spec.num_vertices * spec.num_vertices { + crate::random::lcg_step(&mut state); + } + let weights = (0..graph.num_edges()).map(|_| (crate::random::lcg_step(&mut state) * 9.0) as i32 + 1).collect(); + let count = std::cmp::max(2, spec.num_vertices * 2 / 5); + let terminals = crate::random::lcg_choose(&mut state, spec.num_vertices, count) + .map_err(|error| error.to_string())?; + Ok(SteinerTree::new(graph, weights, terminals)) +}); + 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 random, + 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..236ede264 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}; @@ -19,13 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +74,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. /// @@ -273,9 +306,19 @@ pub(crate) fn is_steiner_tree(graph: &G, terminals: &[usize], selected terminals.iter().all(|&t| visited[t]) } +crate::impl_random_generate!(SteinerTreeInGraphs, crate::random::SimpleGraphRandomSpec, |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let graph = spec.graph()?; + let terminals = (0..std::cmp::max(2, spec.num_vertices / 2)).collect(); + let weights = vec![1; graph.num_edges()]; + Ok(SteinerTreeInGraphs::new(graph, terminals, weights)) +}); + 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 random, + SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/strong_connectivity_augmentation.rs b/src/models/graph/strong_connectivity_augmentation.rs index d22906400..4b67424da 100644 --- a/src/models/graph/strong_connectivity_augmentation.rs +++ b/src/models/graph/strong_connectivity_augmentation.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Add a bounded set of weighted candidate arcs to make a digraph strongly connected", fields: &[ diff --git a/src/models/graph/subgraph_isomorphism.rs b/src/models/graph/subgraph_isomorphism.rs index ca7f7506b..ecbba124d 100644 --- a/src/models/graph/subgraph_isomorphism.rs +++ b/src/models/graph/subgraph_isomorphism.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Subgraph Isomorphism", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine if host graph G contains a subgraph isomorphic to pattern graph H", fields: &[ diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index 017fabf7c..15a13700f 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}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, 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 +55,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 { @@ -259,8 +313,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(TravelingSalesman::new(graph, weights)) +}); + crate::declare_variants! { - default TravelingSalesman => "2^num_vertices", + default TravelingSalesman => "2^num_vertices" create TravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index be86186dd..7d78832be 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}; @@ -25,16 +25,10 @@ inventory::submit! { display_name: "Undirected Flow with Lower Bounds", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +49,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 +287,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..d293f5643 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}; @@ -14,18 +14,10 @@ inventory::submit! { display_name: "Undirected Two-Commodity Integral Flow", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, 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 +48,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 +367,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/additional_key.rs b/src/models/misc/additional_key.rs index 6073fc46c..e1827a9c2 100644 --- a/src/models/misc/additional_key.rs +++ b/src/models/misc/additional_key.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Additional Key", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a relational schema has a candidate key not in a given set", fields: &[ diff --git a/src/models/misc/betweenness.rs b/src/models/misc/betweenness.rs index 2062f6af0..4d63462ed 100644 --- a/src/models/misc/betweenness.rs +++ b/src/models/misc/betweenness.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Betweenness", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a linear ordering where specified elements are between others", fields: &[ diff --git a/src/models/misc/bin_packing.rs b/src/models/misc/bin_packing.rs index a778c2395..25dc9d396 100644 --- a/src/models/misc/bin_packing.rs +++ b/src/models/misc/bin_packing.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Bin Packing", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign items to bins minimizing number of bins used, subject to capacity", fields: &[ diff --git a/src/models/misc/boyce_codd_normal_form_violation.rs b/src/models/misc/boyce_codd_normal_form_violation.rs index 3e465c5bf..1d34f66ae 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; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Boyce-Codd Normal Form Violation", aliases: &["BCNFViolation", "BCNF"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +65,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 +258,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..4008bbe4d 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}; @@ -13,14 +13,10 @@ inventory::submit! { display_name: "Capacity Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +34,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 +216,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/closest_string.rs b/src/models/misc/closest_string.rs index 05c646890..6cfc78658 100644 --- a/src/models/misc/closest_string.rs +++ b/src/models/misc/closest_string.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Closest String", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a center string of fixed length that minimizes the maximum Hamming distance to a list of equal-length input strings", fields: &[ diff --git a/src/models/misc/closest_substring.rs b/src/models/misc/closest_substring.rs index 7e33a1b52..67e825dca 100644 --- a/src/models/misc/closest_substring.rs +++ b/src/models/misc/closest_substring.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Closest Substring", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a center string of fixed length and one length-ell window per input string that minimize the maximum Hamming distance between the center and any selected window", fields: &[ diff --git a/src/models/misc/clustering.rs b/src/models/misc/clustering.rs index 3bb340087..469cbf9dc 100644 --- a/src/models/misc/clustering.rs +++ b/src/models/misc/clustering.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Clustering", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition elements into at most K clusters where all intra-cluster distances are at most B", fields: &[ diff --git a/src/models/misc/conjunctive_boolean_query.rs b/src/models/misc/conjunctive_boolean_query.rs index e9e4b8737..9179d1189 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}; @@ -20,14 +20,10 @@ inventory::submit! { display_name: "Conjunctive Boolean Query", aliases: &["CBQ"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +83,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 +303,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/conjunctive_query_foldability.rs b/src/models/misc/conjunctive_query_foldability.rs index cd3963c50..7e1014ed1 100644 --- a/src/models/misc/conjunctive_query_foldability.rs +++ b/src/models/misc/conjunctive_query_foldability.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Conjunctive Query Foldability", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine if one conjunctive query can be folded into another by substituting undistinguished variables", fields: &[ diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 04ade7a9e..4f8dab42d 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; @@ -88,14 +88,10 @@ inventory::submit! { display_name: "Consistency of Database Frequency Tables", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +104,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 +436,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/cosine_product_integration.rs b/src/models/misc/cosine_product_integration.rs index 1716cc770..595a23b6f 100644 --- a/src/models/misc/cosine_product_integration.rs +++ b/src/models/misc/cosine_product_integration.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Cosine Product Integration", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Decide whether a balanced sign assignment exists for a sequence of integer frequencies", fields: &[ diff --git a/src/models/misc/cyclic_ordering.rs b/src/models/misc/cyclic_ordering.rs index 9087fe497..ba884db6d 100644 --- a/src/models/misc/cyclic_ordering.rs +++ b/src/models/misc/cyclic_ordering.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Cyclic Ordering", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a permutation satisfying cyclic ordering constraints on triples", fields: &[ diff --git a/src/models/misc/dynamic_storage_allocation.rs b/src/models/misc/dynamic_storage_allocation.rs index adcba4d93..8a9c3f6ac 100644 --- a/src/models/misc/dynamic_storage_allocation.rs +++ b/src/models/misc/dynamic_storage_allocation.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Dynamic Storage Allocation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign starting addresses for items with time intervals and sizes within bounded memory", fields: &[ diff --git a/src/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index 479e7eb48..5fcd2476d 100644 --- a/src/models/misc/ensemble_computation.rs +++ b/src/models/misc/ensemble_computation.rs @@ -11,6 +11,7 @@ inventory::submit! { display_name: "Ensemble Computation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find the minimum-length sequence of disjoint unions that builds all required subsets", fields: &[ diff --git a/src/models/misc/expected_retrieval_cost.rs b/src/models/misc/expected_retrieval_cost.rs index 573e6f49d..f6df30c42 100644 --- a/src/models/misc/expected_retrieval_cost.rs +++ b/src/models/misc/expected_retrieval_cost.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Expected Retrieval Cost", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign records to circular storage sectors to minimize expected retrieval latency", fields: &[ diff --git a/src/models/misc/factoring.rs b/src/models/misc/factoring.rs index 9b72b2754..bf71653ba 100644 --- a/src/models/misc/factoring.rs +++ b/src/models/misc/factoring.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Factoring", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Factor a composite integer into two factors", fields: &[ diff --git a/src/models/misc/feasible_register_assignment.rs b/src/models/misc/feasible_register_assignment.rs index 64c0e340a..9f68aafa9 100644 --- a/src/models/misc/feasible_register_assignment.rs +++ b/src/models/misc/feasible_register_assignment.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Feasible Register Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a DAG computation can be scheduled without register conflicts under a fixed assignment", fields: &[ diff --git a/src/models/misc/flow_shop_scheduling.rs b/src/models/misc/flow_shop_scheduling.rs index d29937b8a..a86d6e3c5 100644 --- a/src/models/misc/flow_shop_scheduling.rs +++ b/src/models/misc/flow_shop_scheduling.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Flow Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine if a flow-shop schedule for jobs on m processors meets a deadline", fields: &[ diff --git a/src/models/misc/grouping_by_swapping.rs b/src/models/misc/grouping_by_swapping.rs index ff6cc0d36..eb2185edd 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}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Grouping by Swapping", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +33,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 +205,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/integer_expression_membership.rs b/src/models/misc/integer_expression_membership.rs index 0e47f3006..d7ff2f323 100644 --- a/src/models/misc/integer_expression_membership.rs +++ b/src/models/misc/integer_expression_membership.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Integer Expression Membership", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Decide whether a target integer belongs to the set represented by an expression tree over union and Minkowski sum", fields: &[ diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs index be2dbb4bf..26733f47c 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}; @@ -17,12 +17,10 @@ inventory::submit! { display_name: "Job-Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +30,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 +290,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..d7c9e04e3 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}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Knapsack", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +60,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 +187,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..ef5d378ce 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 _; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Kth Largest m-Tuple", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +65,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 +216,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..351202096 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}; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Longest Common Subsequence", aliases: &["LCS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +42,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 +248,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/maximum_likelihood_ranking.rs b/src/models/misc/maximum_likelihood_ranking.rs index 89d178d46..d4c6361ee 100644 --- a/src/models/misc/maximum_likelihood_ranking.rs +++ b/src/models/misc/maximum_likelihood_ranking.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Maximum Likelihood Ranking", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a ranking minimizing total pairwise disagreement cost", fields: &[ diff --git a/src/models/misc/minimum_axiom_set.rs b/src/models/misc/minimum_axiom_set.rs index 705e155cf..d6cde9bd5 100644 --- a/src/models/misc/minimum_axiom_set.rs +++ b/src/models/misc/minimum_axiom_set.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Axiom Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find smallest axiom subset whose deductive closure equals the true sentences", fields: &[ diff --git a/src/models/misc/minimum_code_generation_one_register.rs b/src/models/misc/minimum_code_generation_one_register.rs index 58fe83a27..b7dde2573 100644 --- a/src/models/misc/minimum_code_generation_one_register.rs +++ b/src/models/misc/minimum_code_generation_one_register.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Code Generation (One Register)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-length instruction sequence for a one-register machine to evaluate an expression DAG", fields: &[ diff --git a/src/models/misc/minimum_code_generation_parallel_assignments.rs b/src/models/misc/minimum_code_generation_parallel_assignments.rs index 09f4595fd..7617ee15b 100644 --- a/src/models/misc/minimum_code_generation_parallel_assignments.rs +++ b/src/models/misc/minimum_code_generation_parallel_assignments.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Minimum Code Generation (Parallel Assignments)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find an ordering of parallel assignments minimizing backward dependencies", fields: &[ diff --git a/src/models/misc/minimum_code_generation_unlimited_registers.rs b/src/models/misc/minimum_code_generation_unlimited_registers.rs index e4144d608..4233734e3 100644 --- a/src/models/misc/minimum_code_generation_unlimited_registers.rs +++ b/src/models/misc/minimum_code_generation_unlimited_registers.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Minimum Code Generation (Unlimited Registers)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-length instruction sequence for an unlimited-register machine with 2-address instructions to evaluate an expression DAG", fields: &[ diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index 453b80089..c3948e944 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}; @@ -15,13 +15,10 @@ inventory::submit! { display_name: "Minimum Decision Tree", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +59,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 +233,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_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index deff9704a..351b58f7a 100644 --- a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Minimum Discrete Planar Inverse Kinematics", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Pick one sampled absolute orientation per link, subject to consecutive-pair feasibility constraints, to minimize the squared distance from the end-effector to a target point", fields: &[ diff --git a/src/models/misc/minimum_disjunctive_normal_form.rs b/src/models/misc/minimum_disjunctive_normal_form.rs index a705a34e3..b4e3211ac 100644 --- a/src/models/misc/minimum_disjunctive_normal_form.rs +++ b/src/models/misc/minimum_disjunctive_normal_form.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Minimum Disjunctive Normal Form", aliases: &["MinDNF"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-term DNF formula equivalent to a Boolean function", fields: &[ diff --git a/src/models/misc/minimum_external_macro_data_compression.rs b/src/models/misc/minimum_external_macro_data_compression.rs index dd6fbbd0d..32d99b11e 100644 --- a/src/models/misc/minimum_external_macro_data_compression.rs +++ b/src/models/misc/minimum_external_macro_data_compression.rs @@ -25,6 +25,7 @@ inventory::submit! { display_name: "Minimum External Macro Data Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-cost compression using an external dictionary and compressed string with pointers", fields: &[ diff --git a/src/models/misc/minimum_fault_detection_test_set.rs b/src/models/misc/minimum_fault_detection_test_set.rs index 43efeae64..9ae36bb75 100644 --- a/src/models/misc/minimum_fault_detection_test_set.rs +++ b/src/models/misc/minimum_fault_detection_test_set.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Fault Detection Test Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum set of input-output paths covering all internal DAG vertices", fields: &[ diff --git a/src/models/misc/minimum_internal_macro_data_compression.rs b/src/models/misc/minimum_internal_macro_data_compression.rs index 15f76309e..34d902f3c 100644 --- a/src/models/misc/minimum_internal_macro_data_compression.rs +++ b/src/models/misc/minimum_internal_macro_data_compression.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Minimum Internal Macro Data Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-cost self-referencing compression of a string with embedded pointers", fields: &[ diff --git a/src/models/misc/minimum_register_sufficiency_for_loops.rs b/src/models/misc/minimum_register_sufficiency_for_loops.rs index fc6d19027..747ee6a2d 100644 --- a/src/models/misc/minimum_register_sufficiency_for_loops.rs +++ b/src/models/misc/minimum_register_sufficiency_for_loops.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Register Sufficiency for Loops", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign registers to loop variables minimizing register count, no two conflicting variables share a register", fields: &[ diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index f507094d5..94c7f7c16 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}; @@ -19,13 +19,10 @@ inventory::submit! { display_name: "Minimum Tardiness Sequencing", aliases: &[], dimensions: &[VariantDimension::new("weight", "One", &["One", "i32"])], + category: crate::registry::ProblemCategory::Misc, 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 +61,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 +302,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..d1a2d2f75 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}; @@ -14,15 +14,10 @@ inventory::submit! { display_name: "Minimum Weight AND/OR Graph", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +73,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 +340,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..7617024ef 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}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Multiprocessor Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +60,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 +150,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/non_liveness_free_petri_net.rs b/src/models/misc/non_liveness_free_petri_net.rs index 322a2e727..cd3584ec9 100644 --- a/src/models/misc/non_liveness_free_petri_net.rs +++ b/src/models/misc/non_liveness_free_petri_net.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Non-Liveness Free Petri Net", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a free-choice Petri net is not live (some transition can become permanently dead)", fields: &[ diff --git a/src/models/misc/numerical_3_dimensional_matching.rs b/src/models/misc/numerical_3_dimensional_matching.rs index cb4363647..db763f518 100644 --- a/src/models/misc/numerical_3_dimensional_matching.rs +++ b/src/models/misc/numerical_3_dimensional_matching.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Numerical 3-Dimensional Matching", aliases: &["N3DM"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition W∪X∪Y into m triples (one from each set) each summing to B", fields: &[ diff --git a/src/models/misc/numerical_matching_with_target_sums.rs b/src/models/misc/numerical_matching_with_target_sums.rs index 377c4438c..fd9857399 100644 --- a/src/models/misc/numerical_matching_with_target_sums.rs +++ b/src/models/misc/numerical_matching_with_target_sums.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Numerical Matching with Target Sums", aliases: &["NMTS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition X∪Y into m pairs (one from X, one from Y) with pair sums matching targets", fields: &[ diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 3db688a38..f5ff161e7 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}; @@ -17,12 +17,10 @@ inventory::submit! { display_name: "Open Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +67,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 +245,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..c354d8acf 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 +69,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 +352,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/paintshop.rs b/src/models/misc/paintshop.rs index b144ced5b..bcf21dc9a 100644 --- a/src/models/misc/paintshop.rs +++ b/src/models/misc/paintshop.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Paint Shop", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize color changes in paint shop sequence", fields: &[ diff --git a/src/models/misc/partially_ordered_knapsack.rs b/src/models/misc/partially_ordered_knapsack.rs index 405dab450..57e9b8fcf 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}; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Partially Ordered Knapsack", aliases: &["POK"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +72,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 +325,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/partition.rs b/src/models/misc/partition.rs index 1f42dddb0..bf9ebd1d8 100644 --- a/src/models/misc/partition.rs +++ b/src/models/misc/partition.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Partition", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a multiset of positive integers can be partitioned into two subsets of equal sum", fields: &[ diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index e5c887e07..15f726e6b 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}; @@ -14,14 +14,10 @@ inventory::submit! { display_name: "Precedence Constrained Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +54,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 +190,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..2533ef869 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}; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Preemptive Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +65,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 +258,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..375a3592b 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}; @@ -16,17 +16,10 @@ inventory::submit! { display_name: "Production Planning", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +35,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 +235,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/rectilinear_picture_compression.rs b/src/models/misc/rectilinear_picture_compression.rs index 13243e40d..50f662755 100644 --- a/src/models/misc/rectilinear_picture_compression.rs +++ b/src/models/misc/rectilinear_picture_compression.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Rectilinear Picture Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Cover all 1-entries of a binary matrix with at most K axis-aligned all-1 rectangles", fields: &[ diff --git a/src/models/misc/register_sufficiency.rs b/src/models/misc/register_sufficiency.rs index 3530cddeb..e843fedcf 100644 --- a/src/models/misc/register_sufficiency.rs +++ b/src/models/misc/register_sufficiency.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Register Sufficiency", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a DAG computation can be performed using K or fewer registers", fields: &[ diff --git a/src/models/misc/resource_constrained_scheduling.rs b/src/models/misc/resource_constrained_scheduling.rs index 4a714371f..c12a38e13 100644 --- a/src/models/misc/resource_constrained_scheduling.rs +++ b/src/models/misc/resource_constrained_scheduling.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Resource Constrained Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule unit-length tasks on m processors with resource constraints and a deadline", fields: &[ 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..46fbb2bfe 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}; @@ -17,13 +17,10 @@ inventory::submit! { display_name: "Scheduling to Minimize Weighted Completion Time", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +62,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 +247,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..e98cb534e 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; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Scheduling With Individual Deadlines", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +36,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 +181,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..ada08db48 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}; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Maximum Cumulative Cost", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +38,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 +187,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..2b16cf9d4 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}; @@ -15,13 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Tardy Task Weight", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +41,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 +189,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..d02e32dd9 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}; @@ -21,13 +21,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Weighted Completion Time", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +43,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 +233,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..c3c5edc3f 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}; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Weighted Tardiness", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +59,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 +188,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_with_deadlines_and_set_up_times.rs b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs index 3b14e6bcf..98f67f2e8 100644 --- a/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs +++ b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Sequencing with Deadlines and Set-Up Times", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether all tasks can be scheduled on a single machine by their deadlines given compiler-switch setup penalties", fields: &[ diff --git a/src/models/misc/sequencing_with_release_times_and_deadlines.rs b/src/models/misc/sequencing_with_release_times_and_deadlines.rs index 35c7c9607..b418549ea 100644 --- a/src/models/misc/sequencing_with_release_times_and_deadlines.rs +++ b/src/models/misc/sequencing_with_release_times_and_deadlines.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Sequencing with Release Times and Deadlines", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Single-machine scheduling feasibility: can all tasks be scheduled within their release-deadline windows without overlap?", fields: &[ diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index cc391444a..53d4d4462 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}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Sequencing Within Intervals", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +60,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 +200,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..cc878de3c 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}; @@ -23,13 +23,10 @@ inventory::submit! { display_name: "Shortest Common Supersequence", aliases: &["SCS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +62,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 +218,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/shortest_common_superstring.rs b/src/models/misc/shortest_common_superstring.rs index 9aabc97d2..82c8ec804 100644 --- a/src/models/misc/shortest_common_superstring.rs +++ b/src/models/misc/shortest_common_superstring.rs @@ -27,6 +27,7 @@ inventory::submit! { display_name: "Shortest Common Superstring", aliases: &["SCSS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a shortest string that contains every input string as a contiguous substring", fields: &[ diff --git a/src/models/misc/square_tiling.rs b/src/models/misc/square_tiling.rs index fe27d4a3a..e61313878 100644 --- a/src/models/misc/square_tiling.rs +++ b/src/models/misc/square_tiling.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Square Tiling", aliases: &["WangTiling"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Place colored square tiles on an N x N grid with matching edge colors", fields: &[ diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index e1b835d28..bcc136adb 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}; @@ -17,15 +17,10 @@ inventory::submit! { display_name: "Stacker Crane", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +41,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 +339,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..eae9d161b 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}; @@ -14,14 +14,10 @@ inventory::submit! { display_name: "Staff Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +34,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 +213,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..0e9df2528 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}; @@ -24,14 +24,10 @@ inventory::submit! { display_name: "String-to-String Correction", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +73,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 +240,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/subset_product.rs b/src/models/misc/subset_product.rs index b82136ed4..478cfc203 100644 --- a/src/models/misc/subset_product.rs +++ b/src/models/misc/subset_product.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Subset Product", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a subset of positive integers whose product equals exactly a target value", fields: &[ diff --git a/src/models/misc/subset_sum.rs b/src/models/misc/subset_sum.rs index d0346613f..a151d418d 100644 --- a/src/models/misc/subset_sum.rs +++ b/src/models/misc/subset_sum.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Subset Sum", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a subset of positive integers that sums to exactly a target value", fields: &[ diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs index 050042bd1..e93e75537 100644 --- a/src/models/misc/sum_of_squares_partition.rs +++ b/src/models/misc/sum_of_squares_partition.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Sum of Squares Partition", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition positive integers into K groups minimizing the sum of squared group sums", fields: &[ diff --git a/src/models/misc/three_partition.rs b/src/models/misc/three_partition.rs index 9131544d4..9f903de08 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 _; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "3-Partition", aliases: &["3Partition", "3-Partition"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +133,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 +185,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..627dc1db7 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}; @@ -14,16 +14,10 @@ inventory::submit! { display_name: "Timetable Design", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, 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 +36,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 +435,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..07e06af21 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; @@ -23,15 +23,10 @@ inventory::submit! { display_name: "Comparative Containment", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["One", "i32", "f64"])], + category: crate::registry::ProblemCategory::Set, 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 +45,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 +277,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/consecutive_sets.rs b/src/models/set/consecutive_sets.rs index 1e50f29dc..6d354b3b4 100644 --- a/src/models/set/consecutive_sets.rs +++ b/src/models/set/consecutive_sets.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Consecutive Sets", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if a string exists where each subset's elements appear consecutively", fields: &[ diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index c65dc0c67..9cc04deff 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; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Exact Cover by 3-Sets", aliases: &["X3C"], dimensions: &[], + category: crate::registry::ProblemCategory::Set, 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 +59,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 +239,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/integer_knapsack.rs b/src/models/set/integer_knapsack.rs index aba6cb1f6..f522ac072 100644 --- a/src/models/set/integer_knapsack.rs +++ b/src/models/set/integer_knapsack.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Integer Knapsack", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Select items with integer multiplicities to maximize total value subject to capacity constraint", fields: &[ diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index fb199d5a2..2b5eb139e 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; @@ -16,12 +16,10 @@ inventory::submit! { display_name: "Maximum Set Packing", aliases: &[], dimensions: &[VariantDimension::new("weight", "One", &["One", "i32", "f64"])], + category: crate::registry::ProblemCategory::Set, 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 +59,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 +187,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_cardinality_key.rs b/src/models/set/minimum_cardinality_key.rs index 7aa90ddaf..01cafecef 100644 --- a/src/models/set/minimum_cardinality_key.rs +++ b/src/models/set/minimum_cardinality_key.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Minimum Cardinality Key", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a candidate key of minimum cardinality in a relational system", fields: &[ diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index e7b0d47d2..04fef79f8 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}; @@ -14,12 +14,10 @@ inventory::submit! { display_name: "Minimum Hitting Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, 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 +38,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 +166,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..fb0aea942 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; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Minimum Set Covering", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + category: crate::registry::ProblemCategory::Set, 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 +65,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 +205,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..ccd9c9ed7 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}; @@ -13,13 +13,10 @@ inventory::submit! { display_name: "Prime Attribute Name", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, 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 +67,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 +247,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/rooted_tree_storage_assignment.rs b/src/models/set/rooted_tree_storage_assignment.rs index b4138f5af..287e3ecfc 100644 --- a/src/models/set/rooted_tree_storage_assignment.rs +++ b/src/models/set/rooted_tree_storage_assignment.rs @@ -11,6 +11,7 @@ inventory::submit! { display_name: "Rooted Tree Storage Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Does there exist a rooted tree whose subset path extensions cost at most K?", fields: &[ diff --git a/src/models/set/set_basis.rs b/src/models/set/set_basis.rs index e1b9f92bf..8620e2961 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}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Set Basis", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, 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 +37,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 +194,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/models/set/set_splitting.rs b/src/models/set/set_splitting.rs index e63053aa7..72bebeed1 100644 --- a/src/models/set/set_splitting.rs +++ b/src/models/set/set_splitting.rs @@ -13,6 +13,7 @@ inventory::submit! { display_name: "Set Splitting", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Partition a universe into two parts so that every subset is non-monochromatic", fields: &[ diff --git a/src/models/set/three_dimensional_matching.rs b/src/models/set/three_dimensional_matching.rs index fcad38548..ab0d9a856 100644 --- a/src/models/set/three_dimensional_matching.rs +++ b/src/models/set/three_dimensional_matching.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Three-Dimensional Matching", aliases: &["3DM"], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a perfect matching in a tripartite hypergraph", fields: &[ diff --git a/src/models/set/three_matroid_intersection.rs b/src/models/set/three_matroid_intersection.rs index 75959467c..0b7cb93ea 100644 --- a/src/models/set/three_matroid_intersection.rs +++ b/src/models/set/three_matroid_intersection.rs @@ -13,6 +13,7 @@ inventory::submit! { display_name: "Three-Matroid Intersection", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a common independent set of size K in three partition matroids", fields: &[ diff --git a/src/models/set/two_dimensional_consecutive_sets.rs b/src/models/set/two_dimensional_consecutive_sets.rs index de247c110..a34a2b1aa 100644 --- a/src/models/set/two_dimensional_consecutive_sets.rs +++ b/src/models/set/two_dimensional_consecutive_sets.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "2-Dimensional Consecutive Sets", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if alphabet can be partitioned into ordered groups with intersection and consecutiveness constraints", fields: &[ diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 000000000..30b136bee --- /dev/null +++ b/src/random.rs @@ -0,0 +1,237 @@ +//! Shared deterministic building blocks for model-owned random generators. + +use crate::registry::ConstructionError; +use crate::topology::SimpleGraph; +use serde::Deserialize; + +/// Inputs shared by models generated from an Erdős–Rényi simple graph. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct SimpleGraphRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent probability of including each possible edge (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Inputs shared by integer-lattice graph generators. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct IntegerGeometryRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Inputs shared by unit-disk graph generators. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct UnitDiskRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Disk radius used to derive edges (default: 1.0). + pub radius: Option, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Random simple-graph inputs with a required clique size. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct CliqueRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Required clique size. + pub k: usize, +} + +impl CliqueRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } +} + +/// Random simple-graph inputs with optional source and sink vertices. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct EndpointRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Source vertex (default: 0). + pub source: Option, + /// Sink vertex (default: the final vertex). + pub sink: Option, +} + +/// Random simple-graph inputs with an optional runtime color count. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct ColoringRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Runtime color count (default: 3). + pub k: Option, +} + +impl ColoringRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } +} + +impl EndpointRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } + + /// Validate and return distinct source and sink vertices. + pub fn endpoints(&self) -> Result<(usize, usize), String> { + if self.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let source = self.source.unwrap_or(0); + let sink = self.sink.unwrap_or(self.num_vertices - 1); + if source >= self.num_vertices || sink >= self.num_vertices { + return Err(format!( + "source and sink must be below num_vertices ({})", + self.num_vertices + )); + } + if source == sink { + return Err("source and sink must be distinct".to_string()); + } + Ok((source, sink)) + } +} + +impl SimpleGraphRandomSpec { + /// Generate the requested graph after validating its probability. + pub fn graph(&self) -> Result { + let edge_prob = self.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + return Err(format!( + "edge_prob must be between 0 and 1, got {edge_prob}" + )); + } + Ok(create_random_graph(self.num_vertices, edge_prob, self.seed)) + } +} + +/// Implement a typed, model-owned random generator using a typed input spec. +#[macro_export] +macro_rules! impl_random_generate { + ($target:ty, $spec:ty, |$input:ident| $body:block) => { + impl $crate::registry::RandomGenerate for $target { + const INPUTS: &'static [$crate::registry::CreateInputInfo] = + <$spec as $crate::registry::CreateSpec>::INPUTS; + + fn generate( + data: serde_json::Value, + ) -> Result { + $crate::registry::validate_create_inputs(Self::INPUTS, &data)?; + let $input: $spec = <$spec as $crate::registry::CreateSpec>::deserialize_inputs( + data, + ) + .map_err(|error| { + $crate::registry::ConstructionError::InvalidInput(error.to_string()) + })?; + let generate = || -> Result { $body }; + let result = generate(); + result.map_err($crate::registry::ConstructionError::Conversion) + } + } + }; +} + +/// LCG PRNG step returning a uniform value in `[0, 1)`. +pub fn lcg_step(state: &mut u64) -> f64 { + *state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (*state >> 33) as f64 / (1u64 << 31) as f64 +} + +/// Initialize LCG state from a seed or the current time. +pub fn lcg_init(seed: Option) -> u64 { + seed.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos() as u64 + }) +} + +/// Generate an Erdős–Rényi simple graph. +pub fn create_random_graph(num_vertices: usize, edge_prob: f64, seed: Option) -> SimpleGraph { + let mut state = lcg_init(seed); + let edges = (0..num_vertices) + .flat_map(|u| ((u + 1)..num_vertices).map(move |v| (u, v))) + .filter(|_| lcg_step(&mut state) < edge_prob) + .collect(); + SimpleGraph::new(num_vertices, edges) +} + +/// Generate unique integer positions on a square grid. +pub fn create_random_int_positions(num_vertices: usize, seed: Option) -> Vec<(i32, i32)> { + let mut state = lcg_init(seed); + let grid_size = (num_vertices as f64).sqrt().ceil() as i32 + 1; + let capacity = (grid_size * grid_size) as usize; + lcg_choose(&mut state, capacity, num_vertices) + .expect("grid capacity exceeds the requested position count") + .into_iter() + .map(|index| (index as i32 / grid_size, index as i32 % grid_size)) + .collect() +} + +/// Generate float positions in `[0, sqrt(N)]²`. +pub fn create_random_float_positions(num_vertices: usize, seed: Option) -> Vec<(f64, f64)> { + let mut state = lcg_init(seed); + let side = (num_vertices as f64).sqrt(); + (0..num_vertices) + .map(|_| (lcg_step(&mut state) * side, lcg_step(&mut state) * side)) + .collect() +} + +/// Choose `k` distinct sorted indices from `0..n`. +pub fn lcg_choose(state: &mut u64, n: usize, k: usize) -> Result, ConstructionError> { + if k > n { + return Err(ConstructionError::Conversion(format!( + "cannot choose {k} elements from {n}" + ))); + } + let mut indices = (0..n).collect::>(); + for i in 0..k { + let j = i + (lcg_step(state) * (n - i) as f64) as usize % (n - i); + indices.swap(i, j); + } + let mut chosen = indices[..k].to_vec(); + chosen.sort_unstable(); + Ok(chosen) +} 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 d253d4c4a..c5a220a27 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -56,13 +56,33 @@ pub use info::{ComplexityClass, FieldInfo, ProblemInfo, ProblemMetadata}; pub use problem_ref::{parse_catalog_problem_ref, require_graph_variant, ProblemRef}; pub use problem_type::{find_problem_type, find_problem_type_by_alias, problem_types, ProblemType}; pub use schema::{ - collect_schemas, declared_size_fields, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson, - ProblemSizeFieldEntry, VariantDimension, + collect_schemas, declared_size_fields, FieldInfoJson, ParseProblemCategoryError, + ProblemCategory, ProblemSchemaEntry, ProblemSchemaJson, ProblemSizeFieldEntry, + VariantDimension, }; pub use variant::{ - find_variant_by_alias, find_variant_entry, validate_variant_aliases, 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, RandomGenerate, + RandomRegistration, 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..509ecff45 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -1,6 +1,6 @@ //! Problem type catalog: runtime lookup by name, alias, and variant validation. -use super::schema::{ProblemSchemaEntry, VariantDimension}; +use super::schema::{ProblemCategory, ProblemSchemaEntry, VariantDimension}; use super::FieldInfo; use std::collections::BTreeMap; @@ -17,8 +17,10 @@ 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], + /// Explicit structural model category. + pub category: ProblemCategory, } impl ProblemType { @@ -31,6 +33,7 @@ impl ProblemType { dimensions: entry.dimensions, description: entry.description, fields: entry.fields, + category: entry.category, } } diff --git a/src/registry/schema.rs b/src/registry/schema.rs index 3fd9dcecd..fa2fcbd44 100644 --- a/src/registry/schema.rs +++ b/src/registry/schema.rs @@ -2,6 +2,73 @@ use super::FieldInfo; use serde::Serialize; +use std::fmt; +use std::str::FromStr; + +/// Structural category used to organize problem implementations and catalog output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ProblemCategory { + Algebraic, + Formula, + Graph, + Misc, + Set, +} + +impl ProblemCategory { + pub const ALL: [Self; 5] = [ + Self::Algebraic, + Self::Formula, + Self::Graph, + Self::Misc, + Self::Set, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Algebraic => "algebraic", + Self::Formula => "formula", + Self::Graph => "graph", + Self::Misc => "misc", + Self::Set => "set", + } + } +} + +impl fmt::Display for ProblemCategory { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Error returned when a catalog category is not one of the five supported values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseProblemCategoryError(String); + +impl fmt::Display for ParseProblemCategoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let expected = ProblemCategory::ALL.map(ProblemCategory::as_str).join(", "); + write!( + formatter, + "unknown problem category `{}`; expected one of: {expected}", + self.0, + ) + } +} + +impl std::error::Error for ParseProblemCategoryError {} + +impl FromStr for ProblemCategory { + type Err = ParseProblemCategoryError; + + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|category| category.as_str() == value) + .ok_or_else(|| ParseProblemCategoryError(value.to_string())) + } +} /// A declared variant dimension for a problem type. /// @@ -33,6 +100,22 @@ impl VariantDimension { } /// A registered problem schema entry for static inventory registration. +/// +/// Category is required rather than inferred from source location: +/// +/// ```compile_fail +/// use problemreductions::registry::ProblemSchemaEntry; +/// +/// let _schema = ProblemSchemaEntry { +/// name: "Example", +/// display_name: "Example", +/// aliases: &[], +/// dimensions: &[], +/// module_path: module_path!(), +/// description: "Example schema", +/// fields: &[], +/// }; +/// ``` pub struct ProblemSchemaEntry { /// Problem name (e.g., "MaximumIndependentSet"). pub name: &'static str, @@ -42,11 +125,13 @@ pub struct ProblemSchemaEntry { pub aliases: &'static [&'static str], /// Declared variant dimensions with defaults and allowed values. pub dimensions: &'static [VariantDimension], + /// Explicit structural category shown in catalog output. + pub category: ProblemCategory, /// Module path from `module_path!()` (e.g., "problemreductions::models::graph::maximum_independent_set"). 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 +157,9 @@ pub struct ProblemSchemaJson { pub name: String, /// Problem description. pub description: String, - /// Struct fields. + /// Structural catalog category. + pub category: ProblemCategory, + /// Inputs accepted when constructing this problem. pub fields: Vec, } @@ -94,6 +181,7 @@ pub fn collect_schemas() -> Vec { .map(|entry| ProblemSchemaJson { name: entry.name.to_string(), description: entry.description.to_string(), + category: entry.category, fields: entry .fields .iter() diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 254fd0539..bfa8c4460 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -4,6 +4,190 @@ 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>; + +/// Random-generation contract for one concrete problem variant. +#[derive(Clone, Copy)] +pub struct RandomRegistration { + /// Inputs accepted by the generator. + pub inputs: &'static [CreateInputInfo], + /// Generate a concrete problem from normalized inputs. + pub generate: ConstructProblemFn, +} + +/// A concrete problem type that can generate itself from typed random inputs. +pub trait RandomGenerate: DynProblem + Sized { + /// Inputs accepted by this model's random generator. + const INPUTS: &'static [CreateInputInfo]; + + /// Generate a concrete problem from normalized random inputs. + fn generate(data: serde_json::Value) -> Result; +} + +/// 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 +212,13 @@ 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, + /// Model-owned random generator for this exact variant. + pub random: Option, /// 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. @@ -53,6 +244,11 @@ impl VariantEntry { } } +/// Return every registered concrete problem variant. +pub fn variant_entries() -> Vec<&'static VariantEntry> { + inventory::iter::().collect() +} + /// Find a variant entry by exact problem name and exact variant map. /// /// No alias resolution or default fallback. Both `name` and `variant` must match exactly. diff --git a/src/rules/graph.rs b/src/rules/graph.rs index e364c8e74..abf1ed105 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -84,8 +84,8 @@ pub(crate) struct NodeJson { pub(crate) name: String, /// Variant attributes as key-value pairs. pub(crate) variant: BTreeMap, - /// Category of the problem (e.g., "graph", "set", "optimization", "satisfiability", "specialized"). - pub(crate) category: String, + /// Structural category declared by the problem schema. + pub(crate) category: crate::registry::ProblemCategory, /// Relative rustdoc path (e.g., "models/graph/maximum_independent_set"). pub(crate) doc_path: String, /// Worst-case time complexity expression (empty if not declared). @@ -341,20 +341,6 @@ impl std::fmt::Display for ReductionStep { } } -/// Classify a problem's category from its module path. -/// Expected format: "problemreductions::models::::" -pub(crate) fn classify_problem_category(module_path: &str) -> &str { - let parts: Vec<&str> = module_path.split("::").collect(); - if parts.len() >= 3 { - if let Some(pos) = parts.iter().position(|&p| p == "models") { - if pos + 1 < parts.len() { - return parts[pos + 1]; - } - } - } - "other" -} - /// Internal node data for the variant-level graph. #[derive(Debug, Clone)] struct VariantNode { @@ -1686,11 +1672,12 @@ impl ReductionGraph { pub(crate) fn to_json(&self) -> ReductionGraphJson { use crate::registry::ProblemSchemaEntry; - // Build name -> module_path lookup from ProblemSchemaEntry inventory - let schema_modules: HashMap<&str, &str> = inventory::iter:: - .into_iter() - .map(|entry| (entry.name, entry.module_path)) - .collect(); + // Build the model-owned metadata lookup from ProblemSchemaEntry inventory. + let schema_metadata: HashMap<&str, (&str, crate::registry::ProblemCategory)> = + inventory::iter:: + .into_iter() + .map(|entry| (entry.name, (entry.module_path, entry.category))) + .collect(); // Build sorted node list from the internal nodes let mut json_nodes: Vec<(usize, NodeJson)> = self @@ -1698,21 +1685,20 @@ impl ReductionGraph { .iter() .enumerate() .map(|(i, node)| { - let (category, doc_path) = if let Some(&mod_path) = schema_modules.get(node.name) { - ( - Self::category_from_module_path(mod_path), - Self::doc_path_from_module_path(mod_path, node.name), - ) - } else { - ("other".to_string(), String::new()) - }; + let &(module_path, category) = + schema_metadata.get(node.name).unwrap_or_else(|| { + panic!( + "missing problem schema for registered variant `{}`", + node.name + ) + }); ( i, NodeJson { name: node.name.to_string(), variant: node.variant.clone(), category, - doc_path, + doc_path: Self::doc_path_from_module_path(module_path, node.name), complexity: node.complexity.to_string(), }, ) @@ -1859,13 +1845,6 @@ impl ReductionGraph { format!("{}/index.html", stripped.replace("::", "/")) } - /// Extract the category from a module path. - /// - /// E.g., `"problemreductions::models::graph::maximum_independent_set"` -> `"graph"`. - fn category_from_module_path(module_path: &str) -> String { - classify_problem_category(module_path).to_string() - } - /// Build the rustdoc path from a module path and problem name. /// /// E.g., `"problemreductions::models::graph::maximum_independent_set"`, `"MaximumIndependentSet"` 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/problem_type.rs b/src/unit_tests/registry/problem_type.rs index 6ca8cfdb3..aa5aac47e 100644 --- a/src/unit_tests/registry/problem_type.rs +++ b/src/unit_tests/registry/problem_type.rs @@ -1,6 +1,6 @@ use crate::registry::{ find_problem_type, find_problem_type_by_alias, parse_catalog_problem_ref, problem_types, - ProblemRef, ProblemSchemaEntry, + ProblemCategory, ProblemRef, ProblemSchemaEntry, }; use std::collections::HashMap; @@ -66,6 +66,43 @@ fn problem_types_returns_all_registered() { .any(|t| t.canonical_name == "MaximumIndependentSet")); } +#[test] +fn problem_category_comes_from_explicit_schema_metadata() { + assert_eq!( + find_problem_type("QUBO").unwrap().category, + ProblemCategory::Algebraic + ); + assert_eq!( + find_problem_type("KSatisfiability").unwrap().category, + ProblemCategory::Formula + ); + assert_eq!( + find_problem_type("MaximumClique").unwrap().category, + ProblemCategory::Graph + ); + assert_eq!( + find_problem_type("JobShopScheduling").unwrap().category, + ProblemCategory::Misc + ); + assert_eq!( + find_problem_type("MinimumSetCovering").unwrap().category, + ProblemCategory::Set + ); + + static MISMATCHED_PATH_SCHEMA: ProblemSchemaEntry = ProblemSchemaEntry { + name: "ExplicitCategoryTest", + display_name: "Explicit category test", + aliases: &[], + dimensions: &[], + category: ProblemCategory::Set, + module_path: "problemreductions::models::graph::explicit_category_test", + description: "Test fixture", + fields: &[], + }; + let problem = super::ProblemType::from_entry(&MISMATCHED_PATH_SCHEMA); + assert_eq!(problem.category, ProblemCategory::Set); +} + #[test] fn problem_ref_from_values_no_values_uses_all_defaults() { let problem = find_problem_type("MaximumIndependentSet").unwrap(); @@ -164,10 +201,20 @@ fn every_public_problem_schema_has_dimension_defaults() { #[test] fn every_alias_is_globally_unique() { + let canonical_names = inventory::iter:: + .into_iter() + .map(|entry| (entry.name.to_lowercase(), entry.name)) + .collect::>(); let mut seen: HashMap = HashMap::new(); for entry in inventory::iter:: { for alias in entry.aliases { let lower = alias.to_lowercase(); + if let Some(canonical) = canonical_names.get(&lower) { + panic!( + "Alias '{}' on {} conflicts with canonical problem name {}", + alias, entry.name, canonical, + ); + } if let Some(prev) = seen.get(&lower) { panic!( "Alias '{}' is used by both {} and {}", diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 44bac3c0d..473759c77 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -1,6 +1,20 @@ use super::*; use crate::registry::find_variant_entry; use std::collections::BTreeMap; +use std::str::FromStr; + +#[test] +fn problem_category_parses_only_declared_values() { + for category in ProblemCategory::ALL { + assert_eq!(ProblemCategory::from_str(category.as_str()), Ok(category)); + } + assert_eq!( + ProblemCategory::from_str("unknown") + .unwrap_err() + .to_string(), + "unknown problem category `unknown`; expected one of: algebraic, formula, graph, misc, set" + ); +} #[test] fn test_collect_schemas_returns_all_problems() { @@ -70,15 +84,17 @@ fn test_schema_json_serialization() { let json = serde_json::to_string(&schemas).expect("Schemas should serialize to JSON"); assert!(json.contains("MaximumIndependentSet")); assert!(json.contains("graph")); + assert!(json.contains("\"category\":\"graph\"")); } #[test] 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..3d33b3456 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -1,5 +1,9 @@ -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_entries, variant_label, +}; +use crate::registry::{ConstructionError, CreateInputCodec, CreateInputInfo, FieldInfo}; +use std::collections::{BTreeMap, BTreeSet}; #[test] fn variant_alias_inventory_is_valid() { @@ -16,6 +20,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![ @@ -122,3 +313,52 @@ fn variant_label_with_variant_dimensions() { "expected label to include k=K3, got: {label}" ); } + +#[test] +fn random_contract_input_names_are_unique() { + let entries = variant_entries(); + assert!(entries.iter().any(|entry| entry.random.is_some())); + + for entry in entries { + let Some(random) = entry.random else { + continue; + }; + let mut names = BTreeSet::new(); + for input in random.inputs { + assert!( + !input.name.is_empty(), + "{} has an empty random input", + variant_label(entry) + ); + assert!( + names.insert(input.name), + "{} declares random input `{}` more than once", + variant_label(entry), + input.name + ); + } + } +} + +#[test] +fn established_random_generation_models_remain_registered() { + let expected = " + DecisionMinimumVertexCover MaximumIndependentSet MinimumVertexCover MaximumClique + MinimumDominatingSet MaximalIS KClique MinimumCutIntoBoundedSets HamiltonianCircuit + HamiltonianPath HamiltonianPathBetweenTwoVertices LongestCircuit MinimumMaximalMatching + RootedTreeArrangement SteinerTree SteinerTreeInGraphs LengthBoundedDisjointPaths + MaximumAchromaticNumber MaximumDomaticNumber MinimumCoveringByCliques + MinimumIntersectionGraphBasis MaximumLeafSpanningTree GeneralizedHex + BottleneckTravelingSalesman MaxCut MaximumMatching TravelingSalesman SpinGlass KColoring + OptimalLinearArrangement MinimumSumMulticenter + "; + let registered = variant_entries() + .into_iter() + .filter(|entry| entry.random.is_some()) + .map(|entry| entry.name) + .collect::>(); + + for name in expected.split_whitespace() { + assert!(registered.contains(name), "{name} lost random generation"); + } +} diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 1e09d066f..36461361a 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -8,7 +8,8 @@ use crate::models::graph::MaxCut; use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; -use crate::rules::graph::{classify_problem_category, ReductionMode, ReductionStep}; +use crate::registry::ProblemCategory; +use crate::rules::graph::{ReductionMode, ReductionStep}; use crate::rules::registry::{ReductionEntry, ReductionSizeDeclarations}; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; use crate::topology::SimpleGraph; @@ -1036,8 +1037,14 @@ fn test_to_json() { // Check nodes assert!(json.nodes.len() >= 10); assert!(json.nodes.iter().any(|n| n.name == "MaximumIndependentSet")); - assert!(json.nodes.iter().any(|n| n.category == "graph")); - assert!(json.nodes.iter().any(|n| n.category == "algebraic")); + assert!(json + .nodes + .iter() + .any(|n| n.category == ProblemCategory::Graph)); + assert!(json + .nodes + .iter() + .any(|n| n.category == ProblemCategory::Algebraic)); // Check edges assert!(json.edges.len() >= 10); @@ -1075,39 +1082,6 @@ fn test_to_json_string() { ); } -#[test] -fn test_category_from_module_path() { - assert_eq!( - ReductionGraph::category_from_module_path( - "problemreductions::models::graph::maximum_independent_set" - ), - "graph" - ); - assert_eq!( - ReductionGraph::category_from_module_path( - "problemreductions::models::set::minimum_set_covering" - ), - "set" - ); - assert_eq!( - ReductionGraph::category_from_module_path("problemreductions::models::algebraic::qubo"), - "algebraic" - ); - assert_eq!( - ReductionGraph::category_from_module_path("problemreductions::models::formula::sat"), - "formula" - ); - assert_eq!( - ReductionGraph::category_from_module_path("problemreductions::models::misc::factoring"), - "misc" - ); - // Fallback for unexpected format - assert_eq!( - ReductionGraph::category_from_module_path("foo::bar"), - "other" - ); -} - #[test] fn test_doc_path_from_module_path() { assert_eq!( @@ -1320,12 +1294,11 @@ fn test_unknown_name_returns_empty() { } #[test] -fn test_category_derived_from_schema() { - // CircuitSAT's category is derived from its ProblemSchemaEntry module_path +fn test_category_comes_from_schema() { let graph = ReductionGraph::new(); let json = graph.to_json(); let circuit = json.nodes.iter().find(|n| n.name == "CircuitSAT").unwrap(); - assert_eq!(circuit.category, "formula"); + assert_eq!(circuit.category, ProblemCategory::Formula); } #[test] @@ -1398,8 +1371,6 @@ fn test_to_json_nodes_have_variants() { for node in &json.nodes { // Verify node has a name assert!(!node.name.is_empty()); - // Verify node has a category - assert!(!node.category.is_empty()); } } @@ -1507,27 +1478,6 @@ fn test_edges_have_doc_paths() { } } -#[test] -fn test_classify_problem_category() { - assert_eq!( - classify_problem_category("problemreductions::models::graph::maximum_independent_set"), - "graph" - ); - assert_eq!( - classify_problem_category("problemreductions::models::formula::satisfiability"), - "formula" - ); - assert_eq!( - classify_problem_category("problemreductions::models::set::maximum_set_packing"), - "set" - ); - assert_eq!( - classify_problem_category("problemreductions::models::algebraic::qubo"), - "algebraic" - ); - assert_eq!(classify_problem_category("unknown::path"), "other"); -} - #[test] fn test_reduce_along_path_direct() { let graph = ReductionGraph::new();