From efb829a504af24feac37ab1108a7d3edc21c8771 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 11 Aug 2026 00:55:17 +0800 Subject: [PATCH 1/7] refactor: build create arguments from schemas --- .claude/CLAUDE.md | 4 +- problemreductions-cli/Cargo.toml | 2 +- problemreductions-cli/src/cli.rs | 1354 ++--------------- problemreductions-cli/src/commands/create.rs | 469 ++---- .../src/commands/create/schema_semantics.rs | 388 +++-- .../src/commands/create/schema_support.rs | 968 ++++-------- .../src/commands/create/tests.rs | 499 ++---- problemreductions-cli/src/create_args.rs | 327 ++++ problemreductions-cli/src/main.rs | 20 +- problemreductions-cli/tests/cli_tests.rs | 347 ++--- src/registry/mod.rs | 3 +- src/registry/variant.rs | 5 + 12 files changed, 1292 insertions(+), 3094 deletions(-) create mode 100644 problemreductions-cli/src/create_args.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ef69387f1..4227c2854 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -204,8 +204,8 @@ 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. +- **CLI creation is schema-driven and deferred:** `pred create` builds lightweight problem/variant command shells, then expands fields only for the selected command. Construction fields are classified as external, derived, or composite; both Clap and the builder consume that classification. Ordinary external fields come from `ProblemSchemaEntry`, derived fields are not exposed as flags, and composite graph fields expand through the existing construction semantics (for example `BipartiteGraph` uses `--left`, `--right`, and `--biedges`). New models do not add fields to a central Clap struct. Add parser support only when introducing a genuinely new field representation. +- **Each CLI input has one name.** Do not add compatibility aliases or multi-name lookup. Schema fields normally use `snake_case → kebab-case`; a composite or derived input may use the single construction name returned by `problem_help_flag_name()`. - **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. Schema-driven CLI creation auto-restructures flat JSON into `{inner: {...}, bound}`. - 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` diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index 5f2745859..5c302cc3e 100644 --- a/problemreductions-cli/Cargo.toml +++ b/problemreductions-cli/Cargo.toml @@ -20,7 +20,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..5350dca08 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,7 +1,8 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; -use std::collections::HashMap; use std::path::PathBuf; +pub use crate::create_args::CreateArgs; + #[derive(Parser)] #[command( name = "pred", @@ -220,998 +221,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 "-

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

` before invoking. Accepts `dims`, `fields`, and `size_getters` parameters for problem-specific size fields. - Problems parameterized by graph type `G` and optionally weight type `W` (problem-dependent) @@ -204,9 +204,9 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI -- **CLI creation is schema-driven and deferred:** `pred create` builds lightweight problem/variant command shells, then expands fields only for the selected command. Construction fields are classified as external, derived, or composite; both Clap and the builder consume that classification. Ordinary external fields come from `ProblemSchemaEntry`, derived fields are not exposed as flags, and composite graph fields expand through the existing construction semantics (for example `BipartiteGraph` uses `--left`, `--right`, and `--biedges`). New models do not add fields to a central Clap struct. Add parser support only when introducing a genuinely new field representation. -- **Each CLI input has one name.** Do not add compatibility aliases or multi-name lookup. Schema fields normally use `snake_case → kebab-case`; a composite or derived input may use the single construction name returned by `problem_help_flag_name()`. -- **Decision variants** of optimization problems use `Decision

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

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

` wrapper instead — see `decision_problem_meta!` + `register_decision_variant!` in `src/models/graph/minimum_vertex_cover.rs` for the pattern | | Inventing short aliases | Only use well-established literature abbreviations (MIS, SAT, TSP); do NOT invent new ones | -| Forgetting CLI flags | Schema-driven create needs matching CLI flags in `CreateArgs` for each `ProblemSchemaEntry` field (snake_case → kebab-case). Also add to `flag_map()`. | -| Missing type parser | If the problem uses a new field type, add a handler in `parse_field_value()` in `create.rs` | -| Schema lists derived fields | Schema should list constructor params, not internal fields (e.g., `matrix, k` not `matrix, m, n, k`) | +| Adding frontend model-name branches | Construction is model-owned. Use a local `CreateSpec` and register it with `declare_variants!`; CLI and MCP must discover it. | +| Hand-maintaining custom construction fields twice | Derive `CreateSpec`, use `LocalCreateSpec::FIELDS` in `ProblemSchemaEntry`, and register the same type in `declare_variants!`. | +| Calling a panicking constructor from `TryFrom` | Validate the spec first and return a descriptive conversion error. | | Missing canonical model example | Add a builder in `src/example_db/model_builders.rs` and keep it aligned with paper/example workflows | | Paper example not tested | Must include `test__paper_example` that verifies the exact instance, solution, and solution count shown in the paper | | Claiming direct ILP solving but leaving ` -> ILP` for later | If the issue promises a direct ILP path, implement that rule in the same PR with exact overhead metadata and production-level ILP tests | diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index 5c302cc3e..be37a9085 100644 --- a/problemreductions-cli/Cargo.toml +++ b/problemreductions-cli/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" description = "CLI tool for exploring NP-hard problem reductions" license = "MIT" repository = "https://github.com/CodingThrust/problem-reductions" +default-run = "pred" [[bin]] name = "pred" diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 41e97fdd8..04fb1dbed 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -1,50 +1,31 @@ use crate::cli::{CreateArgs, ExampleSide}; use crate::dispatch::ProblemJsonOutput; use crate::output::OutputConfig; -use crate::problem_name::{ - resolve_catalog_problem_ref, resolve_problem_ref, unknown_problem_error, -}; +use crate::problem_name::{resolve_problem_ref, unknown_problem_error}; use crate::util; use anyhow::{bail, Context, Result}; use num_bigint::BigUint; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; -use problemreductions::models::algebraic::{ - ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - SparseMatrixCompression, -}; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, LabelledArc, LabelledDigraph, LengthBoundedDisjointPaths, LongestCircuit, - MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumMaximalMatching, - RootedTreeArrangement, SteinerTree, SteinerTreeInGraphs, -}; -use problemreductions::models::misc::{ - CbqRelation, FrequencyTable, KnownValue, QueryArg, SchedulingWithIndividualDeadlines, - ThreePartition, + MinimumCutIntoBoundedSets, MinimumMaximalMatching, RootedTreeArrangement, SteinerTree, + SteinerTreeInGraphs, }; +use problemreductions::models::misc::{CbqRelation, FrequencyTable, KnownValue, QueryArg}; use problemreductions::models::Decision; use problemreductions::prelude::*; use problemreductions::topology::{ - BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, - TriangularSubgraph, UnitDiskGraph, + DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; -mod schema_semantics; -use self::schema_semantics::validate_schema_driven_semantics; mod schema_support; use self::schema_support::*; pub(crate) use self::schema_support::{create_inputs_for, InputValueKind}; -const MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS: &str = - "--graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1"; -const MULTIPLE_COPY_FILE_ALLOCATION_USAGE: &str = - "Usage: pred create MultipleCopyFileAllocation --graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1"; -const EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS: &str = - "--probabilities 0.2,0.15,0.15,0.2,0.1,0.2 --num-sectors 3"; - fn all_data_flags_empty(args: &CreateArgs) -> bool { args.is_empty() } @@ -276,37 +257,6 @@ fn resolve_rule_example( }) } -fn parse_precedence_pairs(raw: Option<&str>) -> Result> { - raw.filter(|s| !s.is_empty()) - .map(|s| { - s.split(',') - .map(|pair| { - let pair = pair.trim(); - let (pred, succ) = pair.split_once('>').ok_or_else(|| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v'", - pair - ) - })?; - let pred = pred.trim().parse::().map_err(|_| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", - pair - ) - })?; - let succ = succ.trim().parse::().map_err(|_| { - anyhow::anyhow!( - "Invalid --precedences value '{}': expected 'u>v' with nonnegative integer indices", - pair - ) - })?; - Ok((pred, succ)) - }) - .collect() - }) - .unwrap_or_else(|| Ok(vec![])) -} - fn parse_job_shop_jobs(raw: &str) -> Result>> { let raw = raw.trim(); if raw.is_empty() { @@ -352,19 +302,6 @@ fn parse_job_shop_jobs(raw: &str) -> Result>> { .collect() } -fn validate_precedence_pairs(precedences: &[(usize, usize)], num_tasks: usize) -> Result<()> { - for &(pred, succ) in precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); - } - Ok(()) -} - fn create_from_example(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let example_spec = args .example @@ -413,9 +350,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let problem = args.problem.as_ref().ok_or_else(|| { anyhow::anyhow!("Missing problem type.\n\nUsage: pred create [FLAGS]") })?; - let resolved = resolve_catalog_problem_ref(problem)?; - let canonical = resolved.name(); - let resolved_variant = resolved.variant().clone(); + let (canonical, resolved_variant) = + crate::create_args::resolve_registered_create_variant(problem); if args.has("random") { return create_random(args, canonical, &resolved_variant, out); @@ -433,11 +369,6 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ); } - // Show schema-driven help when no data flags are provided - if all_data_flags_empty(args) { - bail!("No construction arguments were provided for {canonical}"); - } - let (data, variant) = create_schema_driven(args, canonical, &resolved_variant)?; let output = ProblemJsonOutput { @@ -449,23 +380,6 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { emit_problem_output(&output, out) } -/// Reject non-unit weights when the resolved variant uses `weight=One`. -fn reject_nonunit_weights_for_one_variant( - canonical: &str, - graph_type: &str, - variant: &BTreeMap, - weights: &[i32], -) -> Result<()> { - if variant.get("weight").map(|w| w.as_str()) == Some("One") && weights.iter().any(|&w| w != 1) { - bail!( - "Non-unit weights are not supported for the default unit-weight variant.\n\n\ - Use the weighted variant instead:\n \ - pred create {canonical}/{graph_type}/i32 --graph ... --weights ..." - ); - } - Ok(()) -} - /// Create a vertex-weight problem dispatching on geometry graph type. /// Serialize a vertex-weight problem with a generic graph type. fn ser_vertex_weight_problem_with( @@ -519,260 +433,6 @@ fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { util::variant_map(pairs) } -fn parse_bipartite_problem_input( - args: &CreateArgs, - canonical: &str, - k_description: &str, - usage: &str, -) -> Result<(BipartiteGraph, usize)> { - let left = args.value::("left").ok_or_else(|| { - anyhow::anyhow!( - "{canonical} requires --left, --right, --biedges, and --k\n\nUsage: {usage}" - ) - })?; - let right = args.value::("right").ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --right (right partition size)\n\nUsage: {usage}") - })?; - let k = args.value::("k").ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --k ({k_description})\n\nUsage: {usage}") - })?; - let edges_str = args.raw("biedges").ok_or_else(|| { - anyhow::anyhow!("{canonical} requires --biedges (e.g., 0-0,0-1,1-1)\n\nUsage: {usage}") - })?; - let edges = util::parse_edge_pairs(edges_str)?; - validate_bipartite_edges(canonical, left, right, &edges)?; - Ok((BipartiteGraph::new(left, right, edges), k)) -} - -fn validate_bipartite_edges( - canonical: &str, - left: usize, - right: usize, - edges: &[(usize, usize)], -) -> Result<()> { - for &(u, v) in edges { - if u >= left { - bail!("{canonical} edge {u}-{v} is out of bounds for left partition size {left}"); - } - if v >= right { - bail!("{canonical} edge {u}-{v} is out of bounds for right partition size {right}"); - } - } - Ok(()) -} - -/// Parse `--graph` into a SimpleGraph, optionally preserving isolated vertices -/// via `--num-vertices`. -fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { - let edges_str = args - .raw("graph") - .ok_or_else(|| anyhow::anyhow!("This problem requires --graph (e.g., 0-1,1-2,2-3)"))?; - - if edges_str.trim().is_empty() { - let num_vertices = args.value::("num-vertices").ok_or_else(|| { - anyhow::anyhow!( - "Empty graph string. To create a graph with isolated vertices, pass --num-vertices N as well." - ) - })?; - return Ok((SimpleGraph::empty(num_vertices), num_vertices)); - } - - let edges: Vec<(usize, usize)> = edges_str - .split(',') - .map(|pair| { - let parts: Vec<&str> = pair.trim().split('-').collect(); - if parts.len() != 2 { - bail!("Invalid edge '{}': expected format u-v", pair.trim()); - } - let u: usize = parts[0].parse()?; - let v: usize = parts[1].parse()?; - if u == v { - bail!( - "Self-loop detected: edge {}-{}. Simple graphs do not allow self-loops", - u, - v - ); - } - Ok((u, v)) - }) - .collect::>>()?; - - let inferred_num_vertices = edges - .iter() - .flat_map(|(u, v)| [*u, *v]) - .max() - .map(|m| m + 1) - .unwrap_or(0); - let num_vertices = match args.value::("num-vertices") { - Some(explicit) if explicit < inferred_num_vertices => { - bail!( - "--num-vertices {} is too small for the provided graph; need at least {}", - explicit, - inferred_num_vertices - ); - } - Some(explicit) => explicit, - None => inferred_num_vertices, - }; - - Ok((SimpleGraph::new(num_vertices, edges), num_vertices)) -} - -/// Parse `--positions` as integer grid positions. -fn parse_int_positions(args: &CreateArgs) -> Result> { - let pos_str = args.raw("positions").ok_or_else(|| { - anyhow::anyhow!("This variant requires --positions (e.g., \"0,0;1,0;1,1\")") - })?; - util::parse_positions(pos_str, "0,0") -} - -/// Parse `--positions` as float positions. -fn parse_float_positions(args: &CreateArgs) -> Result> { - let pos_str = args.raw("positions").ok_or_else(|| { - anyhow::anyhow!("This variant requires --positions (e.g., \"0.0,0.0;1.0,0.0;0.5,0.87\")") - })?; - util::parse_positions(pos_str, "0.0,0.0") -} - -/// Parse `--weights` as vertex weights (i32), defaulting to all 1s. -fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result> { - match &args.raw("weights") { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_vertices { - bail!( - "Expected {} weights but got {}", - num_vertices, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_vertices]), - } -} - -fn parse_i32_edge_values( - values: Option<&str>, - num_edges: usize, - value_label: &str, -) -> Result> { - match values { - Some(raw) => { - let parsed: Vec = raw - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if parsed.len() != num_edges { - bail!( - "Expected {} {} values but got {}", - num_edges, - value_label, - parsed.len() - ); - } - Ok(parsed) - } - None => Ok(vec![1i32; num_edges]), - } -} - -fn parse_vertex_i64_values( - raw: Option<&str>, - field_name: &str, - num_vertices: usize, - problem_name: &str, - usage: &str, -) -> Result> { - let raw = - raw.ok_or_else(|| anyhow::anyhow!("{problem_name} requires --{field_name}\n\n{usage}"))?; - let values: Vec = util::parse_comma_list(raw) - .map_err(|e| anyhow::anyhow!("invalid {field_name} list: {e}\n\n{usage}"))?; - if values.len() != num_vertices { - bail!( - "Expected {} {} values but got {}\n\n{}", - num_vertices, - field_name, - values.len(), - usage - ); - } - Ok(values) -} - -/// Parse `--terminals` as comma-separated vertex indices. -fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> { - let s = args - .raw("terminals") - .ok_or_else(|| anyhow::anyhow!("--terminals required (e.g., \"0,2,4\")"))?; - let terminals: Vec = s - .split(',') - .map(|t| t.trim().parse::()) - .collect::, _>>() - .context("invalid terminal index")?; - for &t in &terminals { - anyhow::ensure!( - t < num_vertices, - "terminal {t} >= num_vertices ({num_vertices})" - ); - } - let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect(); - anyhow::ensure!( - distinct_terminals.len() == terminals.len(), - "terminals must be distinct" - ); - anyhow::ensure!(terminals.len() >= 2, "at least 2 terminals required"); - Ok(terminals) -} - -/// Parse `--terminal-pairs` as comma-separated `u-v` vertex pairs. -fn parse_terminal_pairs(args: &CreateArgs, num_vertices: usize) -> Result> { - let raw = args - .raw("terminal-pairs") - .ok_or_else(|| anyhow::anyhow!("--terminal-pairs required (e.g., \"0-3,2-5\")"))?; - let terminal_pairs = util::parse_edge_pairs(raw)?; - anyhow::ensure!( - !terminal_pairs.is_empty(), - "at least 1 terminal pair required" - ); - - let mut used = BTreeSet::new(); - for &(source, sink) in &terminal_pairs { - anyhow::ensure!( - source < num_vertices, - "terminal pair source {source} >= num_vertices ({num_vertices})" - ); - anyhow::ensure!( - sink < num_vertices, - "terminal pair sink {sink} >= num_vertices ({num_vertices})" - ); - anyhow::ensure!(source != sink, "terminal pair endpoints must be distinct"); - anyhow::ensure!( - used.insert(source) && used.insert(sink), - "terminal vertices must be pairwise disjoint across terminal pairs" - ); - } - - Ok(terminal_pairs) -} - -fn ensure_positive_i32_values(values: &[i32], label: &str) -> Result<()> { - if values.iter().any(|&value| value <= 0) { - bail!("All {label} must be positive (> 0)"); - } - Ok(()) -} - -fn ensure_positive_i32(value: i32, label: &str) -> Result<()> { - if value <= 0 { - bail!("{label} must be positive (> 0)"); - } - Ok(()) -} - fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> Result<()> { if vertex >= num_vertices { bail!("{label} {vertex} out of bounds (graph has {num_vertices} vertices)"); @@ -780,11 +440,6 @@ fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> R Ok(()) } -/// Parse `--edge-weights` as per-edge numeric values (i32), defaulting to all 1s. -fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { - parse_i32_edge_values(args.raw("edge-weights"), num_edges, "edge weight") -} - fn validate_vertex_index( label: &str, vertex: usize, @@ -798,161 +453,6 @@ fn validate_vertex_index( bail!("{label} must be less than num_vertices ({num_vertices})\n\n{usage}"); } -/// Parse `--capacities` as edge capacities (u64). -fn parse_capacities(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { - let capacities = args - .raw("capacities") - .ok_or_else(|| anyhow::anyhow!("This problem requires --capacities\n\n{usage}"))?; - let capacities: Vec = capacities - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - if capacities.len() != num_edges { - bail!( - "Expected {} capacities but got {}\n\n{}", - num_edges, - capacities.len(), - usage - ); - } - Ok(capacities) -} - -/// Parse `--lower-bounds` as edge lower bounds (u64). -fn parse_lower_bounds(args: &CreateArgs, num_edges: usize, usage: &str) -> Result> { - let lower_bounds = args.raw("lower-bounds").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --lower-bounds\n\n{usage}") - })?; - let lower_bounds: Vec = lower_bounds - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid lower bound `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - if lower_bounds.len() != num_edges { - bail!( - "Expected {} lower bounds but got {}\n\n{}", - num_edges, - lower_bounds.len(), - usage - ); - } - Ok(lower_bounds) -} - -fn parse_bundle_capacities(args: &CreateArgs, num_bundles: usize, usage: &str) -> Result> { - let capacities = args.raw("bundle-capacities").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --bundle-capacities\n\n{usage}") - })?; - let capacities: Vec = capacities - .split(',') - .map(|s| { - let trimmed = s.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid bundle capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()?; - anyhow::ensure!( - capacities.len() == num_bundles, - "Expected {} bundle capacities but got {}\n\n{}", - num_bundles, - capacities.len(), - usage - ); - for (bundle_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - anyhow::ensure!( - fits, - "bundle capacity {} at bundle index {} is too large for this platform\n\n{}", - capacity, - bundle_index, - usage - ); - anyhow::ensure!( - capacity > 0, - "bundle capacity at bundle index {} must be positive\n\n{}", - bundle_index, - usage - ); - } - Ok(capacities) -} - -/// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. -/// Parse `--fields` as SpinGlass on-site fields (i32), defaulting to all 0s. -/// Check if a CLI string value contains float syntax (a decimal point). -/// Parse `--couplings` as SpinGlass pairwise couplings (f64), defaulting to all 1.0. -/// Parse `--fields` as SpinGlass on-site fields (f64), defaulting to all 0.0. -/// Parse `--clauses` as semicolon-separated clauses of comma-separated literals. -/// E.g., "1,2;-1,3;2,-3" -/// Parse `--subsets` as semicolon-separated sets of comma-separated usize. -/// E.g., "0,1;1,2;0,2" -fn parse_sets(args: &CreateArgs) -> Result>> { - parse_named_sets(args.raw("subsets"), "--subsets") -} - -fn parse_named_sets(sets_str: Option<&str>, flag: &str) -> Result>> { - let sets_str = sets_str - .ok_or_else(|| anyhow::anyhow!("This problem requires {flag} (e.g., \"0,1;1,2;0,2\")"))?; - sets_str - .split(';') - .map(|set| { - set.trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid set element: {}", e)) - }) - .collect() - }) - .collect() -} - -fn parse_homologous_pairs(args: &CreateArgs) -> Result> { - let pairs = args.raw("homologous-pairs").ok_or_else(|| { - anyhow::anyhow!( - "IntegralFlowHomologousArcs requires --homologous-pairs (e.g., \"2=5;4=3\")" - ) - })?; - - pairs - .split(';') - .filter(|entry| !entry.trim().is_empty()) - .map(|entry| { - let entry = entry.trim(); - let (left, right) = entry.split_once('=').ok_or_else(|| { - anyhow::anyhow!( - "Invalid homologous pair '{}': expected format u=v (e.g., 2=5)", - entry - ) - })?; - let left = left.trim().parse::().with_context(|| { - format!("Invalid homologous pair '{}': expected format u=v", entry) - })?; - let right = right.trim().parse::().with_context(|| { - format!("Invalid homologous pair '{}': expected format u=v", entry) - })?; - Ok((left, right)) - }) - .collect() -} - -/// Parse a dependency string as semicolon-separated `lhs>rhs` pairs. -/// E.g., "0,1>2,3;2,3>0,1" -/// Parse a comma-separated list of usize indices. /// Parse `--dependencies` as semicolon-separated "lhs>rhs" pairs. /// E.g., "0,1>2;0,2>3;1,3>4;2,4>5" means {0,1}->{2}, {0,2}->{3}, etc. fn parse_dependencies(input: &str) -> Result, Vec)>> { @@ -986,214 +486,8 @@ fn parse_dependencies(input: &str) -> Result, Vec)>> { .collect() } -fn validate_comparative_containment_sets( - family_name: &str, - flag: &str, - universe_size: usize, - sets: &[Vec], -) -> Result<()> { - for (set_index, set) in sets.iter().enumerate() { - for &element in set { - anyhow::ensure!( - element < universe_size, - "{family_name} set {set_index} from {flag} contains element {element} outside universe of size {universe_size}" - ); - } - } - Ok(()) -} - -/// Parse `--partition` as semicolon-separated groups of comma-separated arc indices. -/// E.g., "0,1;2,3;4,7;5,6" -fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result>> { - let partition_str = args.raw("partition").ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")") - })?; - - let partition: Vec> = partition_str - .split(';') - .map(|group| { - group - .trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid partition index: {}", e)) - }) - .collect() - }) - .collect::>()?; - - let mut seen = vec![false; num_arcs]; - for group in &partition { - for &arc_index in group { - anyhow::ensure!( - arc_index < num_arcs, - "partition arc index {} out of range for {} arcs", - arc_index, - num_arcs - ); - anyhow::ensure!( - !seen[arc_index], - "partition arc index {} appears more than once", - arc_index - ); - seen[arc_index] = true; - } - } - anyhow::ensure!( - seen.iter().all(|present| *present), - "partition must cover every arc exactly once" - ); - - Ok(partition) -} - -fn parse_bundles(args: &CreateArgs, num_arcs: usize, usage: &str) -> Result>> { - let bundles_str = args - .raw("bundles") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --bundles\n\n{usage}"))?; - - let bundles: Vec> = bundles_str - .split(';') - .map(|bundle| { - let bundle = bundle.trim(); - anyhow::ensure!( - !bundle.is_empty(), - "IntegralFlowBundles does not allow empty bundle entries\n\n{usage}" - ); - bundle - .split(',') - .map(|s| { - s.trim().parse::().with_context(|| { - format!("Invalid bundle arc index `{}`\n\n{usage}", s.trim()) - }) - }) - .collect::>>() - }) - .collect::>()?; - - let mut seen_overall = vec![false; num_arcs]; - for (bundle_index, bundle) in bundles.iter().enumerate() { - let mut seen_in_bundle = BTreeSet::new(); - for &arc_index in bundle { - anyhow::ensure!( - arc_index < num_arcs, - "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}\n\n{usage}" - ); - anyhow::ensure!( - seen_in_bundle.insert(arc_index), - "bundle {bundle_index} contains duplicate arc index {arc_index}\n\n{usage}" - ); - seen_overall[arc_index] = true; - } - } - anyhow::ensure!( - seen_overall.iter().all(|covered| *covered), - "bundles must cover every arc at least once\n\n{usage}" - ); - - Ok(bundles) -} - -fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) -> Result { - let raw_bound = args.value::("threshold").ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --threshold\n\n{usage}") - })?; - anyhow::ensure!( - raw_bound >= 0, - "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" - ); - i32::try_from(raw_bound).map_err(|_| { - anyhow::anyhow!( - "MultipleChoiceBranching threshold must fit in a 32-bit signed integer, got {raw_bound}" - ) - }) -} - -/// Parse `--weights` for set-based problems (i32), defaulting to all 1s. -fn parse_named_set_weights( - weights_str: Option<&str>, - num_sets: usize, - flag: &str, -) -> Result> { - match weights_str { - Some(w) => { - let weights: Vec = util::parse_comma_list(w)?; - if weights.len() != num_sets { - bail!( - "Expected {} values for {} but got {}", - num_sets, - flag, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_sets]), - } -} - -fn parse_named_set_weights_f64( - weights_str: Option<&str>, - num_sets: usize, - flag: &str, -) -> Result> { - match weights_str { - Some(w) => { - let weights: Vec = util::parse_comma_list(w)?; - if weights.len() != num_sets { - bail!( - "Expected {} values for {} but got {}", - num_sets, - flag, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1.0f64; num_sets]), - } -} - -fn validate_comparative_containment_i32_weights( - family_name: &str, - flag: &str, - weights: &[i32], -) -> Result<()> { - for (index, weight) in weights.iter().enumerate() { - anyhow::ensure!( - *weight > 0, - "{family_name} weights from {flag} must be positive; found {weight} at index {index}" - ); - } - Ok(()) -} - -fn validate_comparative_containment_f64_weights( - family_name: &str, - flag: &str, - weights: &[f64], -) -> Result<()> { - for (index, weight) in weights.iter().enumerate() { - anyhow::ensure!( - weight.is_finite() && *weight > 0.0, - "{family_name} weights from {flag} must be finite and positive; found {weight} at index {index}" - ); - } - Ok(()) -} - /// Parse `--matrix` as semicolon-separated rows of comma-separated bool values (0/1). /// E.g., "1,0;0,1;1,1" -fn parse_bool_matrix(args: &CreateArgs) -> Result>> { - let matrix_str = args - .raw("matrix") - .ok_or_else(|| anyhow::anyhow!("This problem requires --matrix (e.g., \"1,0;0,1;1,1\")"))?; - parse_bool_rows(matrix_str) -} - fn parse_bool_rows(rows_str: &str) -> Result>> { let matrix: Vec> = rows_str .split(';') @@ -1221,234 +515,6 @@ fn parse_bool_rows(rows_str: &str) -> Result>> { Ok(matrix) } -fn parse_named_u64_list( - raw: Option<&str>, - problem: &str, - flag: &str, - usage: &str, -) -> Result> { - let raw = raw.ok_or_else(|| anyhow::anyhow!("{problem} requires {flag}\n\n{usage}"))?; - util::parse_comma_list(raw).map_err(|err| anyhow::anyhow!("{err}\n\n{usage}")) -} - -fn ensure_named_len(len: usize, expected: usize, flag: &str, usage: &str) -> Result<()> { - anyhow::ensure!( - len == expected, - "{flag} must contain exactly {expected} entries\n\n{usage}" - ); - Ok(()) -} - -fn parse_named_bool_rows(rows: Option<&str>, flag: &str, usage: &str) -> Result>> { - let rows = rows.ok_or_else(|| anyhow::anyhow!("TimetableDesign requires {flag}\n\n{usage}"))?; - parse_bool_rows(rows).map_err(|err| { - let message = err.to_string().replace("--matrix", flag); - anyhow::anyhow!("{message}\n\n{usage}") - }) -} - -fn parse_timetable_requirements(requirements: Option<&str>, usage: &str) -> Result>> { - let requirements = requirements - .ok_or_else(|| anyhow::anyhow!("TimetableDesign requires --requirements\n\n{usage}"))?; - let matrix: Vec> = requirements - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>()?; - - if let Some(expected_width) = matrix.first().map(Vec::len) { - anyhow::ensure!( - matrix.iter().all(|row| row.len() == expected_width), - "All rows in --requirements must have the same length" - ); - } - - Ok(matrix) -} - -fn validate_timetable_design_args( - num_periods: usize, - num_craftsmen: usize, - num_tasks: usize, - craftsman_avail: &[Vec], - task_avail: &[Vec], - requirements: &[Vec], - usage: &str, -) -> Result<()> { - anyhow::ensure!( - craftsman_avail.len() == num_craftsmen, - "craftsman availability row count ({}) must equal num_craftsmen ({})\n\n{}", - craftsman_avail.len(), - num_craftsmen, - usage - ); - anyhow::ensure!( - task_avail.len() == num_tasks, - "task availability row count ({}) must equal num_tasks ({})\n\n{}", - task_avail.len(), - num_tasks, - usage - ); - anyhow::ensure!( - requirements.len() == num_craftsmen, - "requirements row count ({}) must equal num_craftsmen ({})\n\n{}", - requirements.len(), - num_craftsmen, - usage - ); - - for (index, row) in craftsman_avail.iter().enumerate() { - anyhow::ensure!( - row.len() == num_periods, - "craftsman availability row {} has {} periods, expected {}\n\n{}", - index, - row.len(), - num_periods, - usage - ); - } - for (index, row) in task_avail.iter().enumerate() { - anyhow::ensure!( - row.len() == num_periods, - "task availability row {} has {} periods, expected {}\n\n{}", - index, - row.len(), - num_periods, - usage - ); - } - for (index, row) in requirements.iter().enumerate() { - anyhow::ensure!( - row.len() == num_tasks, - "requirements row {} has {} tasks, expected {}\n\n{}", - index, - row.len(), - num_tasks, - usage - ); - } - - Ok(()) -} - -/// Parse `--matrix` as semicolon-separated rows of comma-separated f64 values. -/// E.g., "1,0.5;0.5,2" -fn parse_matrix(args: &CreateArgs) -> Result>> { - let matrix_str = args - .raw("matrix") - .ok_or_else(|| anyhow::anyhow!("QUBO requires --matrix (e.g., \"1,0.5;0.5,2\")"))?; - - matrix_str - .split(';') - .map(|row| { - row.trim() - .split(',') - .map(|s| { - s.trim() - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid matrix value: {}", e)) - }) - .collect() - }) - .collect() -} - -fn parse_u64_matrix_rows(matrix_str: &str, matrix_name: &str) -> Result>> { - matrix_str - .split(';') - .enumerate() - .map(|(row_index, row)| { - let row = row.trim(); - anyhow::ensure!( - !row.is_empty(), - "{matrix_name} row {row_index} must not be empty" - ); - row.split(',') - .map(|value| { - value.trim().parse::().map_err(|error| { - anyhow::anyhow!( - "Invalid {matrix_name} row {row_index} value {:?}: {}", - value.trim(), - error - ) - }) - }) - .collect() - }) - .collect() -} - -/// Parse `--quantifiers` as comma-separated quantifier labels (E/A or Exists/ForAll). -/// E.g., "E,A,E" or "Exists,ForAll,Exists" -/// Parse a semicolon-separated matrix of i64 values. -/// E.g., "0,5;5,0" -fn parse_potential_edges(args: &CreateArgs) -> Result> { - let edges_str = args.raw("potential-weights").ok_or_else(|| { - anyhow::anyhow!( - "BiconnectivityAugmentation requires --potential-weights (e.g., 0-2:3,1-3:5)" - ) - })?; - - edges_str - .split(',') - .map(|entry| { - let entry = entry.trim(); - let (edge_part, weight_part) = entry.split_once(':').ok_or_else(|| { - anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") - })?; - let (u_str, v_str) = edge_part.split_once('-').ok_or_else(|| { - anyhow::anyhow!("Invalid potential edge '{entry}': expected u-v:w") - })?; - let u = u_str.trim().parse::()?; - let v = v_str.trim().parse::()?; - if u == v { - bail!("Self-loop detected in potential edge {u}-{v}"); - } - let weight = weight_part.trim().parse::()?; - Ok((u, v, weight)) - }) - .collect() -} - -fn validate_potential_edges( - graph: &SimpleGraph, - potential_edges: &[(usize, usize, i32)], -) -> Result<()> { - let num_vertices = graph.num_vertices(); - let mut seen_potential_edges = BTreeSet::new(); - for &(u, v, _) in potential_edges { - if u >= num_vertices || v >= num_vertices { - bail!( - "Potential edge {u}-{v} references a vertex outside the graph (num_vertices = {num_vertices})" - ); - } - let edge = if u <= v { (u, v) } else { (v, u) }; - if graph.has_edge(edge.0, edge.1) { - bail!( - "Potential edge {}-{} already exists in the graph", - edge.0, - edge.1 - ); - } - if !seen_potential_edges.insert(edge) { - bail!( - "Duplicate potential edge {}-{} is not allowed", - edge.0, - edge.1 - ); - } - } - Ok(()) -} - -fn parse_budget(args: &CreateArgs) -> Result { - let budget = args - .raw("budget") - .ok_or_else(|| anyhow::anyhow!("BiconnectivityAugmentation requires --budget (e.g., 5)"))?; - budget - .parse::() - .map_err(|e| anyhow::anyhow!("Invalid budget '{budget}': {e}")) -} - /// Parse `--arcs` as directed arc pairs and build a `DirectedGraph`. /// /// Returns `(graph, num_arcs)`. Infers vertex count from arc endpoints @@ -1496,92 +562,6 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } -fn parse_prescribed_paths( - args: &CreateArgs, - num_arcs: usize, - usage: &str, -) -> Result>> { - let paths_str = args - .raw("paths") - .ok_or_else(|| anyhow::anyhow!("PathConstrainedNetworkFlow requires --paths\n\n{usage}"))?; - - paths_str - .split(';') - .map(|path_str| { - let trimmed = path_str.trim(); - anyhow::ensure!( - !trimmed.is_empty(), - "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" - ); - let path: Vec = util::parse_comma_list(trimmed)?; - anyhow::ensure!( - !path.is_empty(), - "PathConstrainedNetworkFlow paths must be non-empty\n\n{usage}" - ); - for &arc_idx in &path { - anyhow::ensure!( - arc_idx < num_arcs, - "Path arc index {arc_idx} out of bounds for {num_arcs} arcs\n\n{usage}" - ); - } - Ok(path) - }) - .collect() -} - -fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result { - let (undirected_graph, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let arcs_str = args - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?; - let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices)) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - Ok(MixedGraph::new( - num_vertices, - directed_graph.arcs(), - undirected_graph.edges(), - )) -} - -/// Parse `--weights` as arc weights (i32), defaulting to all 1s. -fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { - match &args.raw("weights") { - Some(w) => { - let weights: Vec = w - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if weights.len() != num_arcs { - bail!( - "Expected {} arc weights but got {}", - num_arcs, - weights.len() - ); - } - Ok(weights) - } - None => Ok(vec![1i32; num_arcs]), - } -} - -/// Parse StackerCrane `--arc-lengths` as per-arc costs, defaulting to all 1s. -fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { - match &args.raw("arc-lengths") { - Some(costs) => { - let parsed: Vec = costs - .split(',') - .map(|s| s.trim().parse::()) - .collect::, _>>()?; - if parsed.len() != num_arcs { - bail!("Expected {} arc costs but got {}", num_arcs, parsed.len()); - } - Ok(parsed) - } - None => Ok(vec![1i32; num_arcs]), - } -} - /// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. pub(super) fn supports_random(name: &str) -> bool { matches!( diff --git a/problemreductions-cli/src/commands/create/schema_semantics.rs b/problemreductions-cli/src/commands/create/schema_semantics.rs deleted file mode 100644 index 6554978f4..000000000 --- a/problemreductions-cli/src/commands/create/schema_semantics.rs +++ /dev/null @@ -1,1321 +0,0 @@ -use super::schema_support::*; -use super::*; - -pub(super) fn validate_schema_driven_semantics( - args: &CreateArgs, - canonical: &str, - resolved_variant: &BTreeMap, - _data: &serde_json::Value, -) -> Result<()> { - match canonical { - "BalancedCompleteBipartiteSubgraph" => { - let usage = "pred create BalancedCompleteBipartiteSubgraph --left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3"; - let _ = parse_bipartite_problem_input( - args, - "BalancedCompleteBipartiteSubgraph", - "balanced biclique size", - usage, - )?; - } - "BiconnectivityAugmentation" => { - let usage = "Usage: pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let potential_edges = parse_potential_edges(args)?; - validate_potential_edges(&graph, &potential_edges)?; - let _ = parse_budget(args)?; - } - "BoundedComponentSpanningForest" => { - let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --max-weight 6"; - let (_, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - args.raw("weights").ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --weights\n\n{usage}") - })?; - let weights = parse_vertex_weights(args, n)?; - if weights.iter().any(|&weight| weight < 0) { - bail!("BoundedComponentSpanningForest requires nonnegative --weights\n\n{usage}"); - } - let max_components = args.value::("k").ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --k\n\n{usage}") - })?; - if max_components == 0 { - bail!("BoundedComponentSpanningForest requires --k >= 1\n\n{usage}"); - } - let bound_raw = args.value::("max-weight").ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") - })?; - if bound_raw <= 0 { - bail!("BoundedComponentSpanningForest requires positive --max-weight\n\n{usage}"); - } - let _ = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - } - "CapacityAssignment" => { - let usage = "Usage: pred create CapacityAssignment --capacities 1,2,3 --cost \"1,3,6;2,4,7;1,2,5\" --delay \"8,4,1;7,3,1;6,3,1\" --delay-budget 12"; - let capacities_str = args.raw("capacities").ok_or_else(|| { - anyhow::anyhow!( - "CapacityAssignment requires --capacities, --cost, --delay, and --delay-budget\n\n{usage}" - ) - })?; - let cost_matrix_str = args - .raw("cost") - .ok_or_else(|| anyhow::anyhow!("CapacityAssignment requires --cost\n\n{usage}"))?; - let delay_matrix_str = args - .raw("delay") - .ok_or_else(|| anyhow::anyhow!("CapacityAssignment requires --delay\n\n{usage}"))?; - let _ = args.value::("delay-budget").ok_or_else(|| { - anyhow::anyhow!("CapacityAssignment requires --delay-budget\n\n{usage}") - })?; - - let capacities: Vec = util::parse_comma_list(capacities_str)?; - anyhow::ensure!( - !capacities.is_empty(), - "CapacityAssignment requires at least one capacity value\n\n{usage}" - ); - anyhow::ensure!( - capacities.iter().all(|&capacity| capacity > 0), - "CapacityAssignment capacities must be positive\n\n{usage}" - ); - anyhow::ensure!( - capacities.windows(2).all(|w| w[0] < w[1]), - "CapacityAssignment capacities must be strictly increasing\n\n{usage}" - ); - - let cost = parse_u64_matrix_rows(cost_matrix_str, "cost")?; - let delay = parse_u64_matrix_rows(delay_matrix_str, "delay")?; - anyhow::ensure!( - cost.len() == delay.len(), - "cost matrix row count ({}) must match delay matrix row count ({})\n\n{usage}", - cost.len(), - delay.len() - ); - - for (index, row) in cost.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "cost row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] <= w[1]), - "cost row {} must be non-decreasing\n\n{usage}", - index - ); - } - for (index, row) in delay.iter().enumerate() { - anyhow::ensure!( - row.len() == capacities.len(), - "delay row {} length ({}) must match capacities length ({})\n\n{usage}", - index, - row.len(), - capacities.len() - ); - anyhow::ensure!( - row.windows(2).all(|w| w[0] >= w[1]), - "delay row {} must be non-increasing\n\n{usage}", - index - ); - } - } - "BoyceCoddNormalFormViolation" => { - let n = args.value::("n").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --n, --subsets, and --target\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let sets_str = args.raw("subsets").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --subsets (functional deps as lhs:rhs;...)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let target_str = args.raw("target").ok_or_else(|| { - anyhow::anyhow!( - "BoyceCoddNormalFormViolation requires --target (comma-separated attribute indices)\n\n\ - Usage: pred create BoyceCoddNormalFormViolation --n 6 --subsets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" - ) - })?; - let _ = parse_bcnf_functional_deps(sets_str, n)?; - let target: Vec = util::parse_comma_list(target_str)?; - ensure_attribute_indices_in_range(&target, n, "Target subset")?; - } - "ClosestVectorProblem" => { - let basis_str = args.raw("basis").ok_or_else(|| { - anyhow::anyhow!( - "CVP requires --basis, --target-vec\n\n\ - Usage: pred create CVP --basis \"1,0;0,1\" --target-vec \"0.5,0.5\"" - ) - })?; - let target_str = args - .raw("target-vec") - .ok_or_else(|| anyhow::anyhow!("CVP requires --target-vec (e.g., \"0.5,0.5\")"))?; - let basis: Vec> = basis_str - .split(';') - .map(|row| util::parse_comma_list(row.trim())) - .collect::>>()?; - let target: Vec = util::parse_comma_list(target_str)?; - let n = basis.len(); - let bounds = serde_json::from_value(parse_cvp_bounds_value( - args.raw("bounds"), - &CreateContext::default() - .with_field("basis", serde_json::json!(vec![serde_json::json!([0]); n])), - )?)?; - let _ = ClosestVectorProblem::new(basis, target, bounds); - } - "ConsecutiveOnesMatrixAugmentation" => { - let matrix = parse_bool_matrix(args)?; - let bound = args.value::("bound").ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveOnesMatrixAugmentation requires --matrix and --bound\n\n\ - Usage: pred create ConsecutiveOnesMatrixAugmentation --matrix \"1,0,0,1,1;1,1,0,0,0;0,1,1,0,1;0,0,1,1,0\" --bound 2" - ) - })?; - ConsecutiveOnesMatrixAugmentation::try_new(matrix, bound) - .map_err(anyhow::Error::msg)?; - } - "ConsecutiveBlockMinimization" => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix_str = args.raw("matrix").ok_or_else(|| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array and --bound-k\n\n{usage}" - ) - })?; - let bound = args.value::("bound-k").ok_or_else(|| { - anyhow::anyhow!("ConsecutiveBlockMinimization requires --bound-k\n\n{usage}") - })?; - let matrix: Vec> = serde_json::from_str(matrix_str).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - ConsecutiveBlockMinimization::try_new(matrix, bound) - .map_err(|err| anyhow::anyhow!("{err}\n\n{usage}"))?; - } - "ComparativeContainment" => { - let universe = args.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "ComparativeContainment requires --universe-size, --r-sets, and --s-sets\n\n\ - Usage: pred create ComparativeContainment --universe-size 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]" - ) - })?; - let r_sets = parse_named_sets(args.raw("r-sets"), "--r-sets")?; - let s_sets = parse_named_sets(args.raw("s-sets"), "--s-sets")?; - validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?; - validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?; - match resolved_variant.get("weight").map(|value| value.as_str()) { - Some("One") => { - let r_weights = parse_named_set_weights( - args.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - let s_weights = parse_named_set_weights( - args.raw("s-weights"), - s_sets.len(), - "--s-weights", - )?; - anyhow::ensure!( - r_weights.iter().all(|&w| w == 1) && s_weights.iter().all(|&w| w == 1), - "Non-unit weights are not supported for ComparativeContainment/One.\n\n\ - Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances." - ); - } - Some("f64") => { - let r_weights = parse_named_set_weights_f64( - args.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights_f64( - args.raw("s-weights"), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?; - } - Some("i32") | None => { - let r_weights = parse_named_set_weights( - args.raw("r-weights"), - r_sets.len(), - "--r-weights", - )?; - validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?; - let s_weights = parse_named_set_weights( - args.raw("s-weights"), - s_sets.len(), - "--s-weights", - )?; - validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?; - } - Some(other) => bail!( - "Unsupported ComparativeContainment weight variant: {}", - other - ), - } - } - "DisjointConnectingPaths" => { - let usage = - "Usage: pred create DisjointConnectingPaths --graph 0-1,1-3,0-2,1-4,2-4,3-5,4-5 --terminal-pairs 0-3,2-5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_terminal_pairs(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "ExactCoverBy3Sets" => { - let universe = args.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "ExactCoverBy3Sets requires --universe-size and --subsets\n\n\ - Usage: pred create X3C --universe-size 6 --subsets \"0,1,2;3,4,5\"" - ) - })?; - if universe % 3 != 0 { - bail!("Universe size must be divisible by 3, got {}", universe); - } - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - if set.len() != 3 { - bail!( - "Subset {} has {} elements, but X3C requires exactly 3 elements per subset", - i, - set.len() - ); - } - if set[0] == set[1] || set[0] == set[2] || set[1] == set[2] { - bail!("Subset {} contains duplicate elements: {:?}", i, set); - } - for &elem in set { - if elem >= universe { - bail!( - "Subset {} contains element {} which is outside universe of size {}", - i, - elem, - universe - ); - } - } - } - } - "GeneralizedHex" => { - let usage = - "Usage: pred create GeneralizedHex --graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let num_vertices = graph.num_vertices(); - let source = args - .value::("source") - .ok_or_else(|| anyhow::anyhow!("GeneralizedHex requires --source\n\n{usage}"))?; - let sink = args - .value::("sink") - .ok_or_else(|| anyhow::anyhow!("GeneralizedHex requires --sink\n\n{usage}"))?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - anyhow::ensure!( - source != sink, - "GeneralizedHex requires distinct --source and --sink\n\n{usage}" - ); - } - "GroupingBySwapping" => { - let usage = - "Usage: pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 [--alphabet-size 3]"; - let string_str = args.raw("string").ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --string\n\n{usage}") - })?; - let bound = parse_nonnegative_usize_bound( - args.value::("bound").ok_or_else(|| { - anyhow::anyhow!("GroupingBySwapping requires --bound\n\n{usage}") - })?, - "GroupingBySwapping", - usage, - )?; - let string = parse_symbol_list_allow_empty(string_str)?; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - let alphabet_size = args.value::("alphabet-size").unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the input string", - alphabet_size, - inferred - ); - anyhow::ensure!( - alphabet_size > 0 || string.is_empty(), - "GroupingBySwapping requires a positive alphabet for non-empty strings.\n\n{usage}" - ); - anyhow::ensure!( - !string.is_empty() || bound == 0, - "GroupingBySwapping requires --bound 0 when --string is empty.\n\n{usage}" - ); - } - "IntegralFlowBundles" => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - let _ = parse_bundle_capacities(args, bundles.len(), usage)?; - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --source\n\n{usage}") - })?; - let sink = args - .value::("sink") - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --sink\n\n{usage}"))?; - let _ = args.value::("requirement").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowBundles requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, graph.num_vertices(), usage)?; - validate_vertex_index("sink", sink, graph.num_vertices(), usage)?; - anyhow::ensure!( - source != sink, - "IntegralFlowBundles requires distinct --source and --sink\n\n{usage}" - ); - } - "IntegralFlowHomologousArcs" => { - let usage = "Usage: pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\""; - let arcs_str = args.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(s) = args.raw("capacities") { - s.split(',') - .map(|token| { - let trimmed = token.trim(); - trimmed - .parse::() - .with_context(|| format!("Invalid capacity `{trimmed}`\n\n{usage}")) - }) - .collect::>>()? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage - ); - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - anyhow::ensure!( - fits, - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage - ); - } - let num_vertices = graph.num_vertices(); - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --sink\n\n{usage}") - })?; - let _ = args.value::("requirement").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowHomologousArcs requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - let homologous_pairs = - parse_homologous_pairs(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - for &(a, b) in &homologous_pairs { - anyhow::ensure!( - a < num_arcs && b < num_arcs, - "homologous pair ({}, {}) references arc >= num_arcs ({})\n\n{}", - a, - b, - num_arcs, - usage - ); - } - } - "IntegralFlowWithMultipliers" => { - let usage = "Usage: pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2"; - let arcs_str = args.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities_str = args.raw("capacities").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --capacities\n\n{usage}") - })?; - let capacities: Vec = util::parse_comma_list(capacities_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if capacities.len() != num_arcs { - bail!( - "Expected {} capacities but got {}\n\n{}", - num_arcs, - capacities.len(), - usage - ); - } - for (arc_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at arc index {} is too large for this platform\n\n{}", - capacity, - arc_index, - usage - ); - } - } - let num_vertices = graph.num_vertices(); - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --sink\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - if source == sink { - bail!( - "IntegralFlowWithMultipliers requires distinct --source and --sink\n\n{}", - usage - ); - } - let multipliers_str = args.raw("multipliers").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --multipliers\n\n{usage}") - })?; - let multipliers: Vec = util::parse_comma_list(multipliers_str) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if multipliers.len() != num_vertices { - bail!( - "Expected {} multipliers but got {}\n\n{}", - num_vertices, - multipliers.len(), - usage - ); - } - if multipliers - .iter() - .enumerate() - .any(|(vertex, &multiplier)| vertex != source && vertex != sink && multiplier == 0) - { - bail!("non-terminal multipliers must be positive\n\n{usage}"); - } - let _ = args.value::("requirement").ok_or_else(|| { - anyhow::anyhow!("IntegralFlowWithMultipliers requires --requirement\n\n{usage}") - })?; - } - "JobShopScheduling" => { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let job_tasks = args - .raw("jobs") - .ok_or_else(|| anyhow::anyhow!("JobShopScheduling requires --jobs\n\n{usage}"))?; - let jobs = parse_job_shop_jobs(job_tasks)?; - let inferred_processors = jobs - .iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1); - let num_processors = args - .value::("num-processors") - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - anyhow::ensure!( - num_processors > 0, - "JobShopScheduling requires --num-processors > 0\n\n{usage}" - ); - for (job_index, job) in jobs.iter().enumerate() { - for (task_index, &(processor, _)) in job.iter().enumerate() { - anyhow::ensure!( - processor < num_processors, - "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" - ); - } - for (task_index, pair) in job.windows(2).enumerate() { - anyhow::ensure!( - pair[0].0 != pair[1].0, - "job {job_index} tasks {task_index} and {} must use different processors\n\n{usage}", - task_index + 1 - ); - } - } - } - "KClique" => { - let usage = "Usage: pred create KClique --graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_kclique_threshold(args.value::("k"), graph.num_vertices(), usage)?; - } - "KColoring" => { - let usage = "Usage: pred create KColoring --graph 0-1,1-2,2-0 --k 3"; - let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = util::validate_k_param( - resolved_variant, - args.value::("k"), - None, - "KColoring", - ) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "KthBestSpanningTree" => { - reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; - let usage = - "Usage: pred create KthBestSpanningTree --graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_edge_weights(args, graph.num_edges())?; - let _ = util::validate_k_param( - resolved_variant, - args.value::("k"), - None, - "KthBestSpanningTree", - ) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args - .value::("bound") - .ok_or_else(|| anyhow::anyhow!("KthBestSpanningTree requires --bound\n\n{usage}"))? - as i32; - } - "LengthBoundedDisjointPaths" => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --sink\n\n{usage}") - })?; - let bound = args.value::("max-length").ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let _ = validate_length_bounded_disjoint_paths_args( - graph.num_vertices(), - source, - sink, - bound, - Some(usage), - )?; - } - "LongestCommonSubsequence" => { - let usage = - "Usage: pred create LCS --strings \"010110;100101;001011\" [--alphabet-size 2]"; - let strings_str = args.raw("strings").ok_or_else(|| { - anyhow::anyhow!("LongestCommonSubsequence requires --strings\n\n{usage}") - })?; - let (strings, inferred_alphabet_size) = parse_lcs_strings(strings_str)?; - let alphabet_size = args - .value::("alphabet-size") - .unwrap_or(inferred_alphabet_size); - anyhow::ensure!( - alphabet_size >= inferred_alphabet_size, - "--alphabet-size {} is smaller than the inferred alphabet size ({})", - alphabet_size, - inferred_alphabet_size - ); - anyhow::ensure!( - strings.iter().any(|string| !string.is_empty()), - "LongestCommonSubsequence requires at least one non-empty string.\n\n{usage}" - ); - anyhow::ensure!( - alphabet_size > 0, - "LongestCommonSubsequence requires a positive alphabet. Provide --alphabet-size when all strings are empty.\n\n{usage}" - ); - } - "LongestPath" => { - let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; - let (graph, _) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; - if args.raw("weights").is_some() { - bail!("LongestPath uses --edge-lengths, not --weights\n\nUsage: {usage}"); - } - let edge_lengths_raw = args.raw("edge-lengths").ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --edge-lengths\n\nUsage: {usage}") - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - let source_vertex = args.value::("source-vertex").ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --source-vertex\n\nUsage: {usage}") - })?; - let target_vertex = args.value::("target-vertex").ok_or_else(|| { - anyhow::anyhow!("LongestPath requires --target-vertex\n\nUsage: {usage}") - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - } - "MixedChinesePostman" => { - let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-weights 2,3,1,4 [--num-vertices N]"; - let graph = parse_mixed_graph(args, usage)?; - let arc_costs = parse_arc_costs(args, graph.num_arcs())?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - if arc_costs.iter().any(|&cost| cost < 0) { - bail!("MixedChinesePostman --arc-weights must be non-negative\n\n{usage}"); - } - if edge_weights.iter().any(|&weight| weight < 0) { - bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); - } - if resolved_variant.get("weight").map(String::as_str) == Some("One") - && (arc_costs.iter().any(|&cost| cost != 1) - || edge_weights.iter().any(|&weight| weight != 1)) - { - bail!( - "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ - Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-weights ..." - ); - } - } - "MinMaxMulticenter" => { - let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2"; - let (graph, n) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let vertex_weights = parse_vertex_weights(args, n)?; - let edge_lengths = parse_edge_weights(args, graph.num_edges())?; - let _ = args.value::("k").ok_or_else(|| { - anyhow::anyhow!( - "MinMaxMulticenter requires --k (number of centers)\n\n\ - Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --k 2" - ) - })?; - if vertex_weights.iter().any(|&weight| weight < 0) { - bail!("MinMaxMulticenter --weights must be non-negative"); - } - if edge_lengths.iter().any(|&length| length < 0) { - bail!("MinMaxMulticenter --edge-weights must be non-negative"); - } - } - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" - | "MaximalIS" => { - let graph_type = resolved_graph_type(resolved_variant); - let num_vertices = match graph_type { - "KingsSubgraph" | "TriangularSubgraph" => parse_int_positions(args)?.len(), - "UnitDiskGraph" => parse_float_positions(args)?.len(), - _ => { - parse_graph(args) - .map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", - canonical - ) - })? - .1 - } - }; - let weights = parse_vertex_weights(args, num_vertices)?; - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - } - "MaximumCoKPlex" => { - let usage = "Usage: pred create MaximumCoKPlex/i32 --graph 0-1,1-2,2-3,3-4,4-0 --weights 5,1,4,1,3 --k 2"; - let (_, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let weights = parse_vertex_weights(args, num_vertices)?; - let graph_type = resolved_graph_type(resolved_variant); - reject_nonunit_weights_for_one_variant( - canonical, - graph_type, - resolved_variant, - &weights, - )?; - let k = args - .value::("k") - .ok_or_else(|| anyhow::anyhow!("MaximumCoKPlex requires --k\n\n{usage}"))?; - if k == 0 { - bail!("MaximumCoKPlex: --k must be at least 1\n\n{usage}"); - } - } - "MinimumHittingSet" => { - let universe = args.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "MinimumHittingSet requires --universe-size and --subsets\n\n\ - Usage: pred create MinimumHittingSet --universe-size 6 --subsets \"0,1,2;0,3,4;1,3,5;2,4,5;0,1,5;2,3;1,4\"" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } - } - } - "MinimumDummyActivitiesPert" => { - let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; - let arcs_str = args.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("MinimumDummyActivitiesPert requires --arcs\n\n{usage}") - })?; - let (graph, _) = parse_directed_graph(arcs_str, args.value::("num-vertices"))?; - let _ = MinimumDummyActivitiesPert::try_new(graph).map_err(anyhow::Error::msg)?; - } - "MinimumMultiwayCut" => { - let usage = - "Usage: pred create MinimumMultiwayCut --graph 0-1,1-2,2-3 --terminals 0,2 [--edge-weights 1,1,1]"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_terminals(args, graph.num_vertices())?; - let _ = parse_edge_weights(args, graph.num_edges())?; - } - "MultipleChoiceBranching" => { - let usage = "Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --threshold 10"; - let arcs_str = args.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") - })?; - let (_, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices"))?; - let _ = parse_arc_weights(args, num_arcs)?; - let _ = parse_partition_groups(args, num_arcs)?; - let _ = parse_multiple_choice_branching_threshold(args, usage)?; - } - "MultipleCopyFileAllocation" => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - let _ = parse_vertex_i64_values( - args.raw("usage"), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - let _ = parse_vertex_i64_values( - args.raw("storage"), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?; - } - "MultiprocessorScheduling" => { - let usage = "Usage: pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10"; - let lengths_str = args.raw("lengths").ok_or_else(|| { - anyhow::anyhow!( - "MultiprocessorScheduling requires --lengths, --num-processors, and --deadline\n\n{usage}" - ) - })?; - let num_processors = args.value::("num-processors").ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --num-processors\n\n{usage}") - })?; - anyhow::ensure!( - num_processors > 0, - "MultiprocessorScheduling requires --num-processors > 0\n\n{usage}" - ); - let _ = args.value::("deadline").ok_or_else(|| { - anyhow::anyhow!("MultiprocessorScheduling requires --deadline\n\n{usage}") - })?; - let _: Vec = util::parse_comma_list(lengths_str)?; - } - "PartialFeedbackEdgeSet" => { - let usage = "Usage: pred create PartialFeedbackEdgeSet --graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4"; - let _ = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = args - .raw("budget") - .ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}") - })? - .parse::() - .map_err(|e| { - anyhow::anyhow!( - "Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}" - ) - })?; - let _ = args.value::("max-cycle-length").ok_or_else(|| { - anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}") - })?; - } - "PathConstrainedNetworkFlow" => { - let usage = "Usage: pred create PathConstrainedNetworkFlow --arcs \"0>1,0>2,1>3,1>4,2>4,3>5,4>5,4>6,5>7,6>7\" --capacities 2,1,1,1,1,1,1,1,2,1 --source 0 --sink 7 --paths \"0,2,5,8;0,3,6,8;0,3,7,9;1,4,6,8;1,4,7,9\" --requirement 3"; - let arcs_str = args.raw("arcs").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --arcs\n\n{usage}") - })?; - let (graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices")) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities: Vec = if let Some(s) = args.raw("capacities") { - util::parse_comma_list(s)? - } else { - vec![1; num_arcs] - }; - anyhow::ensure!( - capacities.len() == num_arcs, - "capacities length ({}) must match number of arcs ({num_arcs})", - capacities.len() - ); - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --sink\n\n{usage}") - })?; - let _ = args.value::("requirement").ok_or_else(|| { - anyhow::anyhow!("PathConstrainedNetworkFlow requires --requirement\n\n{usage}") - })?; - let paths = parse_prescribed_paths(args, num_arcs, usage)?; - validate_prescribed_paths_against_graph(&graph, &paths, source, sink, usage)?; - } - "ProductionPlanning" => { - let usage = "Usage: pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80"; - let num_periods = args.value::("num-periods").ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --num-periods\n\n{usage}") - })?; - let demands = parse_named_u64_list( - args.raw("demands"), - "ProductionPlanning", - "--demands", - usage, - )?; - let capacities = parse_named_u64_list( - args.raw("capacities"), - "ProductionPlanning", - "--capacities", - usage, - )?; - let setup_costs = parse_named_u64_list( - args.raw("setup-costs"), - "ProductionPlanning", - "--setup-costs", - usage, - )?; - let production_costs = parse_named_u64_list( - args.raw("production-costs"), - "ProductionPlanning", - "--production-costs", - usage, - )?; - let inventory_costs = parse_named_u64_list( - args.raw("inventory-costs"), - "ProductionPlanning", - "--inventory-costs", - usage, - )?; - let _ = args.value::("cost-bound").ok_or_else(|| { - anyhow::anyhow!("ProductionPlanning requires --cost-bound\n\n{usage}") - })?; - - for (flag, len) in [ - ("--demands", demands.len()), - ("--capacities", capacities.len()), - ("--setup-costs", setup_costs.len()), - ("--production-costs", production_costs.len()), - ("--inventory-costs", inventory_costs.len()), - ] { - ensure_named_len(len, num_periods, flag, usage)?; - } - } - "SchedulingWithIndividualDeadlines" => { - let usage = "Usage: pred create SchedulingWithIndividualDeadlines --num-tasks 7 --deadlines 2,1,2,2,3,3,2 --num-processors 3 [--precedences \"0>3,1>3,1>4,2>4,2>5\"]"; - let deadlines_str = args.raw("deadlines").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --deadlines, --num-tasks, and --num-processors\n\n{usage}" - ) - })?; - let num_tasks = args.value::("num-tasks").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-tasks (number of tasks)\n\n{usage}" - ) - })?; - let num_processors = args.value::("num-processors").ok_or_else(|| { - anyhow::anyhow!( - "SchedulingWithIndividualDeadlines requires --num-processors\n\n{usage}" - ) - })?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - let precedences = parse_precedence_pairs(args.raw("precedences"))?; - anyhow::ensure!( - deadlines.len() == num_tasks, - "deadlines length ({}) must equal num_tasks ({})", - deadlines.len(), - num_tasks - ); - for &(pred, succ) in &precedences { - anyhow::ensure!( - pred < num_tasks && succ < num_tasks, - "precedence index out of range: ({}, {}) but num_tasks = {}", - pred, - succ, - num_tasks - ); - } - let _ = SchedulingWithIndividualDeadlines::new( - num_tasks, - num_processors, - deadlines, - precedences, - ); - } - "StringToStringCorrection" => { - let usage = "Usage: pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2"; - let source_str = args.raw("source-string").ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --source-string\n\n{usage}") - })?; - let target_str = args.raw("target-string").ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --target-string\n\n{usage}") - })?; - let _ = parse_nonnegative_usize_bound( - args.value::("bound").ok_or_else(|| { - anyhow::anyhow!("StringToStringCorrection requires --bound\n\n{usage}") - })?, - "StringToStringCorrection", - usage, - )?; - let source = parse_symbol_list_allow_empty(source_str)?; - let target = parse_symbol_list_allow_empty(target_str)?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |m| m + 1); - let alphabet_size = args.value::("alphabet-size").unwrap_or(inferred); - anyhow::ensure!( - alphabet_size >= inferred, - "--alphabet-size {} is smaller than max symbol + 1 ({}) in the strings", - alphabet_size, - inferred - ); - } - "SparseMatrixCompression" => { - let matrix = parse_bool_matrix(args)?; - let usage = "Usage: pred create SparseMatrixCompression --matrix \"1,0,0,1;0,1,0,0;0,0,1,0;1,0,0,0\" --bound-k 2"; - let bound = args.value::("bound-k").ok_or_else(|| { - anyhow::anyhow!( - "SparseMatrixCompression requires --matrix and --bound-k\n\n{usage}" - ) - })?; - let bound = parse_nonnegative_usize_bound(bound, "SparseMatrixCompression", usage)?; - if bound == 0 { - anyhow::bail!("SparseMatrixCompression requires bound >= 1\n\n{usage}"); - } - let _ = SparseMatrixCompression::new(matrix, bound); - } - "StackerCrane" => { - let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-lengths 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --num-vertices 6"; - let arcs_str = args - .raw("arcs") - .ok_or_else(|| anyhow::anyhow!("StackerCrane requires --arcs\n\n{usage}"))?; - let (arcs_graph, num_arcs) = - parse_directed_graph(arcs_str, args.value::("num-vertices"))?; - let (edges_graph, num_vertices) = - parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - anyhow::ensure!( - edges_graph.num_vertices() == num_vertices, - "internal error: inconsistent graph vertex count" - ); - anyhow::ensure!( - num_vertices == arcs_graph.num_vertices(), - "StackerCrane requires the directed and undirected inputs to agree on --num-vertices\n\n{usage}" - ); - let arc_lengths = parse_arc_costs(args, num_arcs)?; - let edge_lengths = parse_i32_edge_values( - args.raw("edge-lengths"), - edges_graph.num_edges(), - "edge length", - )?; - let _ = problemreductions::models::misc::StackerCrane::try_new( - num_vertices, - arcs_graph.arcs(), - edges_graph.edges(), - arc_lengths, - edge_lengths, - ) - .map_err(|e| anyhow::anyhow!(e))?; - } - "ThreePartition" => { - let sizes_str = args.raw("sizes").ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --sizes and --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = args.value::("bound").ok_or_else(|| { - anyhow::anyhow!( - "ThreePartition requires --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let bound = u64::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "ThreePartition requires a positive integer --bound\n\n\ - Usage: pred create ThreePartition --sizes 4,5,6,4,6,5 --bound 15" - ) - })?; - let sizes: Vec = util::parse_comma_list(sizes_str)?; - let _ = ThreePartition::try_new(sizes, bound).map_err(anyhow::Error::msg)?; - } - "UndirectedFlowLowerBounds" => { - let usage = "Usage: pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - let lower_bounds = parse_lower_bounds(args, graph.num_edges(), usage)?; - let num_vertices = graph.num_vertices(); - let source = args.value::("source").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --source\n\n{usage}") - })?; - let sink = args.value::("sink").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --sink\n\n{usage}") - })?; - let requirement = args.value::("requirement").ok_or_else(|| { - anyhow::anyhow!("UndirectedFlowLowerBounds requires --requirement\n\n{usage}") - })?; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - let _ = UndirectedFlowLowerBounds::new( - graph, - capacities, - lower_bounds, - source, - sink, - requirement, - ); - } - "SequencingToMinimizeMaximumCumulativeCost" => { - let costs_str = args.raw("costs").ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeMaximumCumulativeCost requires --costs\n\n\ - Usage: pred create SequencingToMinimizeMaximumCumulativeCost --costs 2,-1,3,-2,1,-3 --precedences \"0>2,1>2,1>3,2>4,3>5,4>5\"" - ) - })?; - let costs: Vec = util::parse_comma_list(costs_str)?; - let precedences = parse_precedence_pairs(args.raw("precedences"))?; - validate_precedence_pairs(&precedences, costs.len())?; - } - "SequencingToMinimizeWeightedTardiness" => { - let lengths_str = args.raw("lengths").ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --lengths, --weights, --deadlines, and --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let weights_str = args.raw("weights").ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --weights (comma-separated tardiness weights)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let deadlines_str = args.raw("deadlines").ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --deadlines (comma-separated job deadlines)\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - let bound = args.value::("bound").ok_or_else(|| { - anyhow::anyhow!( - "SequencingToMinimizeWeightedTardiness requires --bound\n\n\ - Usage: pred create SequencingToMinimizeWeightedTardiness --lengths 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" - ) - })?; - anyhow::ensure!(bound >= 0, "--bound must be non-negative"); - let lengths: Vec = util::parse_comma_list(lengths_str)?; - let weights: Vec = util::parse_comma_list(weights_str)?; - let deadlines: Vec = util::parse_comma_list(deadlines_str)?; - anyhow::ensure!( - lengths.len() == weights.len(), - "lengths length ({}) must equal weights length ({})", - lengths.len(), - weights.len() - ); - anyhow::ensure!( - lengths.len() == deadlines.len(), - "lengths length ({}) must equal deadlines length ({})", - lengths.len(), - deadlines.len() - ); - } - "SequencingWithinIntervals" => { - let usage = - "Usage: pred create SequencingWithinIntervals --release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1"; - let rt_str = args.raw("release-times").ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --release-times\n\n{usage}") - })?; - let dl_str = args.raw("deadlines").ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --deadlines\n\n{usage}") - })?; - let len_str = args.raw("lengths").ok_or_else(|| { - anyhow::anyhow!("SequencingWithinIntervals requires --lengths\n\n{usage}") - })?; - let release_times: Vec = util::parse_comma_list(rt_str)?; - let deadlines: Vec = util::parse_comma_list(dl_str)?; - let lengths: Vec = util::parse_comma_list(len_str)?; - validate_sequencing_within_intervals_inputs( - &release_times, - &deadlines, - &lengths, - usage, - )?; - } - "SetBasis" => { - let universe = args.value::("universe-size").ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --universe-size, --subsets, and --k\n\n\ - Usage: pred create SetBasis --universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let _ = args.value::("k").ok_or_else(|| { - anyhow::anyhow!( - "SetBasis requires --k\n\n\ - Usage: pred create SetBasis --universe-size 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3" - ) - })?; - let sets = parse_sets(args)?; - for (i, set) in sets.iter().enumerate() { - for &element in set { - if element >= universe { - bail!( - "Set {} contains element {} which is outside universe of size {}", - i, - element, - universe - ); - } - } - } - } - "ShortestWeightConstrainedPath" => { - let usage = "Usage: pred create ShortestWeightConstrainedPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,1-4 --edge-lengths 2,4,3,1,5,4,2,6 --edge-weights 5,1,2,3,2,3,1,1 --source-vertex 0 --target-vertex 5 --weight-bound 8"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - if args.raw("weights").is_some() { - bail!( - "ShortestWeightConstrainedPath uses --edge-weights, not --weights\n\nUsage: {usage}" - ); - } - let edge_lengths_raw = args.raw("edge-lengths").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-lengths\n\nUsage: {usage}" - ) - })?; - let edge_weights_raw = args.raw("edge-weights").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --edge-weights\n\nUsage: {usage}" - ) - })?; - let edge_lengths = - parse_i32_edge_values(Some(edge_lengths_raw), graph.num_edges(), "edge length")?; - let edge_weights = - parse_i32_edge_values(Some(edge_weights_raw), graph.num_edges(), "edge weight")?; - ensure_positive_i32_values(&edge_lengths, "edge lengths")?; - ensure_positive_i32_values(&edge_weights, "edge weights")?; - let source_vertex = args.value::("source-vertex").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --source-vertex\n\nUsage: {usage}" - ) - })?; - let target_vertex = args.value::("target-vertex").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --target-vertex\n\nUsage: {usage}" - ) - })?; - let weight_bound = args.value::("weight-bound").ok_or_else(|| { - anyhow::anyhow!( - "ShortestWeightConstrainedPath requires --weight-bound\n\nUsage: {usage}" - ) - })?; - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - ensure_positive_i32(weight_bound, "weight_bound")?; - } - "SteinerTree" => { - let usage = "Usage: pred create SteinerTree --graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let _ = parse_edge_weights(args, graph.num_edges())?; - let _ = parse_terminals(args, graph.num_vertices()) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - } - "TimetableDesign" => { - let usage = "Usage: pred create TimetableDesign --num-periods 3 --num-craftsmen 5 --num-tasks 5 --craftsman-avail \"1,1,1;1,1,0;0,1,1;1,0,1;1,1,1\" --task-avail \"1,1,0;0,1,1;1,0,1;1,1,1;1,1,1\" --requirements \"1,0,1,0,0;0,1,0,0,1;0,0,0,1,0;0,0,0,0,1;0,1,0,0,0\""; - let num_periods = args.value::("num-periods").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-periods\n\n{usage}") - })?; - let num_craftsmen = args.value::("num-craftsmen").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-craftsmen\n\n{usage}") - })?; - let num_tasks = args.value::("num-tasks").ok_or_else(|| { - anyhow::anyhow!("TimetableDesign requires --num-tasks\n\n{usage}") - })?; - let craftsman_avail = - parse_named_bool_rows(args.raw("craftsman-avail"), "--craftsman-avail", usage)?; - let task_avail = parse_named_bool_rows(args.raw("task-avail"), "--task-avail", usage)?; - let requirements = parse_timetable_requirements(args.raw("requirements"), usage)?; - validate_timetable_design_args( - num_periods, - num_craftsmen, - num_tasks, - &craftsman_avail, - &task_avail, - &requirements, - usage, - )?; - } - "UndirectedTwoCommodityIntegralFlow" => { - let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let capacities = parse_capacities(args, graph.num_edges(), usage)?; - for (edge_index, &capacity) in capacities.iter().enumerate() { - let fits = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(); - if !fits { - bail!( - "capacity {} at edge index {} is too large for this platform\n\n{}", - capacity, - edge_index, - usage - ); - } - } - let num_vertices = graph.num_vertices(); - let source_1 = args.value::("source-1").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-1\n\n{usage}") - })?; - let sink_1 = args.value::("sink-1").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-1\n\n{usage}") - })?; - let source_2 = args.value::("source-2").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --source-2\n\n{usage}") - })?; - let sink_2 = args.value::("sink-2").ok_or_else(|| { - anyhow::anyhow!("UndirectedTwoCommodityIntegralFlow requires --sink-2\n\n{usage}") - })?; - let _ = args.value::("requirement-1").ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-1\n\n{usage}" - ) - })?; - let _ = args.value::("requirement-2").ok_or_else(|| { - anyhow::anyhow!( - "UndirectedTwoCommodityIntegralFlow requires --requirement-2\n\n{usage}" - ) - })?; - for (label, vertex) in [ - ("source-1", source_1), - ("sink-1", sink_1), - ("source-2", source_2), - ("sink-2", sink_2), - ] { - validate_vertex_index(label, vertex, num_vertices, usage)?; - } - } - _ => {} - } - - Ok(()) -} diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 2a6d95700..bf2c8e896 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -25,20 +25,7 @@ pub(crate) struct CreateInput { pub kind: InputValueKind, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FieldConstructionMode { - External, - Derived, - MixedGraph, - BipartiteGraph, -} - impl CreateContext { - pub(super) fn with_field(mut self, name: &str, value: serde_json::Value) -> Self { - self.parsed_fields.insert(name.to_string(), value); - self - } - fn seed_field(&mut self, name: &str, value: T) -> Result<()> { let value = serde_json::to_value(value)?; if name == "num_vertices" { @@ -122,6 +109,13 @@ pub(super) fn create_schema_driven( }, )?; + if let Some(inputs) = variant_entry.create_inputs { + let data = normalize_registered_create_inputs(args, inputs, resolved_variant) + .map_err(|error| with_registered_usage(error, canonical, inputs))?; + return construct_canonical(canonical, resolved_variant, data) + .map_err(|error| with_registered_usage(error, canonical, inputs)); + } + let graph_type = resolved_graph_type(resolved_variant); let is_geometry = matches!( graph_type, @@ -129,109 +123,151 @@ pub(super) fn create_schema_driven( ); let mut context = CreateContext::default(); seed_schema_context_from_cli(args, graph_type, &mut context)?; - validate_schema_driven_semantics(args, canonical, resolved_variant, &serde_json::Value::Null) - .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; let mut json_map = serde_json::Map::new(); for field in schema.fields { let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); - let flag_name = problem_help_flag_name(canonical, field.name, field.type_name, is_geometry); - let raw_value = args.raw(&flag_name); - let construction_mode = field_construction_mode(canonical, field.name, &concrete_type); - let value = if construction_mode == FieldConstructionMode::Derived { - derive_schema_field_value(args, canonical, field.name, &concrete_type, &context)? - .ok_or_else(|| { - anyhow::anyhow!("No construction rule derives {canonical}.{}", field.name) - })? - } else if construction_mode == FieldConstructionMode::External { - if let Some(raw_value) = raw_value { - match parse_schema_field_value( - args, - canonical, - &concrete_type, - field.name, - raw_value, - &context, - ) { - Ok(value) => value, - Err(error) => { - return Err(with_schema_usage(error, canonical, resolved_variant)) - } - } - } else if let Some(derived) = - derive_schema_field_value(args, canonical, field.name, &concrete_type, &context)? - { - derived - } else { - return Err(with_schema_usage( - missing_schema_field_error(canonical, field.name, field.type_name, is_geometry), - canonical, - resolved_variant, - )); - } - } else if let Some(derived) = - derive_schema_field_value(args, canonical, field.name, &concrete_type, &context)? - { - derived - } else if let Some(raw_value) = raw_value { - match parse_schema_field_value( - args, - canonical, - &concrete_type, - field.name, - raw_value, - &context, - ) { - Ok(value) => value, - Err(error) => return Err(with_schema_usage(error, canonical, resolved_variant)), - } - } else { - return Err(with_schema_usage( + let flag_name = problem_help_flag_name(field.name, field.type_name, is_geometry); + let raw_value = args.raw(&flag_name).ok_or_else(|| { + with_schema_usage( missing_schema_field_error(canonical, field.name, field.type_name, is_geometry), canonical, resolved_variant, - )); - }; + ) + })?; + let value = parse_schema_field_value(&concrete_type, field.name, raw_value, &context) + .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; context.remember(field.name, &concrete_type, &value); json_map.insert(field.name.to_string(), value); } - // KColoring/KN stores the number of colors at runtime in `num_colors`. - // The schema only declares `graph`, so inject `num_colors` from --k for KN. - if canonical == "KColoring" && resolved_variant.get("k").map(|s| s.as_str()) == Some("KN") { - if let Some(k) = args.value::("k") { - json_map.insert("num_colors".to_string(), serde_json::json!(k)); + let data = serde_json::Value::Object(json_map); + construct_canonical(canonical, resolved_variant, data) +} + +fn construct_canonical( + canonical: &str, + resolved_variant: &BTreeMap, + data: serde_json::Value, +) -> Result<(serde_json::Value, BTreeMap)> { + let problem = problemreductions::registry::construct_dyn(canonical, resolved_variant, data)?; + let constructed_variant = problem.variant_map(); + anyhow::ensure!( + problem.problem_name() == canonical && constructed_variant == *resolved_variant, + "registered constructor for {canonical} {resolved_variant:?} returned {} {constructed_variant:?}", + problem.problem_name(), + ); + Ok((problem.serialize_json(), constructed_variant)) +} + +fn normalize_registered_create_inputs( + args: &CreateArgs, + inputs: &[problemreductions::registry::CreateInputInfo], + resolved_variant: &BTreeMap, +) -> Result { + let mut values = serde_json::Map::new(); + for input in inputs { + let flag_name = input.name.replace('_', "-"); + if let Some(raw) = args.raw(&flag_name) { + let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); + values.insert( + input.name.to_string(), + normalize_registered_input(input, &concrete_type, raw)?, + ); } } + Ok(serde_json::Value::Object(values)) +} - // Decision

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

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

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

From> for Decision

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

) -> Self { + Self::new(spec.inner, spec.bound) + } +} + /// Decision version of an optimization problem with a fixed objective bound. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 4bb5f4935..acec604d4 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -5,7 +5,7 @@ //! DAG, each group's total vertex weight is bounded, and the total //! inter-partition arc cost is bounded. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,13 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Partition a directed graph into bounded-weight groups with an acyclic quotient graph and bounded inter-partition cost", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "arc_costs", type_name: "Vec", description: "Arc costs c(a) for each arc a in A, matching graph.arcs() order" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Maximum total vertex weight B for each partition" }, - FieldInfo { name: "cost_bound", type_name: "W::Sum", description: "Maximum total inter-partition arc cost K" }, - ], + fields: AcyclicPartitionCreateSpec::FIELDS, } } @@ -50,6 +44,68 @@ pub struct AcyclicPartition { cost_bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct AcyclicPartitionCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(name = "arc_costs", codec = "comma-separated")] + arc_weights: Option>, + weight_bound: i64, + cost_bound: i64, +} + +impl TryFrom for AcyclicPartition { + type Error = String; + + fn try_from(spec: AcyclicPartitionCreateSpec) -> Result { + if spec.arcs.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty arc list".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let vertex_weights = spec.weights.unwrap_or_else(|| vec![1; num_vertices]); + if vertex_weights.len() != num_vertices { + return Err(format!( + "weights has length {}, expected {num_vertices}", + vertex_weights.len() + )); + } + let arc_costs = spec + .arc_weights + .unwrap_or_else(|| vec![1; graph.num_arcs()]); + if arc_costs.len() != graph.num_arcs() { + return Err(format!( + "arc_weights has length {}, expected {}", + arc_costs.len(), + graph.num_arcs() + )); + } + Ok(Self::new( + graph, + vertex_weights, + arc_costs, + spec.weight_bound, + spec.cost_bound, + )) + } +} + impl AcyclicPartition { /// Create a new Acyclic Partition instance. pub fn new( @@ -237,7 +293,7 @@ fn is_valid_acyclic_partition( } crate::declare_variants! { - default AcyclicPartition => "num_vertices^num_vertices", + default AcyclicPartition => "num_vertices^num_vertices" create AcyclicPartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/balanced_complete_bipartite_subgraph.rs b/src/models/graph/balanced_complete_bipartite_subgraph.rs index fe2ce502f..9f7bd8122 100644 --- a/src/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,4 +1,4 @@ -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -12,10 +12,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Decide whether a bipartite graph contains a K_{k,k} subgraph", - fields: &[ - FieldInfo { name: "graph", type_name: "BipartiteGraph", description: "The bipartite graph G = (A, B, E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Balanced biclique size" }, - ], + fields: BalancedCompleteBipartiteSubgraphCreateSpec::FIELDS, } } @@ -28,6 +25,44 @@ pub struct BalancedCompleteBipartiteSubgraph { edge_lookup: HashSet<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BalancedCompleteBipartiteSubgraphCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Balanced biclique size. + k: usize, +} + +impl TryFrom for BalancedCompleteBipartiteSubgraph { + type Error = String; + + fn try_from(spec: BalancedCompleteBipartiteSubgraphCreateSpec) -> Result { + for (index, &(left, right)) in spec.biedges.iter().enumerate() { + if left >= spec.left { + return Err(format!( + "biedges[{index}] left vertex {left} is out of bounds for left partition size {}", + spec.left + )); + } + if right >= spec.right { + return Err(format!( + "biedges[{index}] right vertex {right} is out of bounds for right partition size {}", + spec.right + )); + } + } + Ok(Self::new( + BipartiteGraph::new(spec.left, spec.right, spec.biedges), + spec.k, + )) + } +} + impl BalancedCompleteBipartiteSubgraph { pub fn new(graph: BipartiteGraph, k: usize) -> Self { let edge_lookup = Self::build_edge_lookup(&graph); @@ -144,7 +179,7 @@ impl From for BalancedCompleteBipartiteSu } crate::declare_variants! { - default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices", + default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices" create BalancedCompleteBipartiteSubgraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biclique_cover.rs b/src/models/graph/biclique_cover.rs index ef94bad62..63c36f754 100644 --- a/src/models/graph/biclique_cover.rs +++ b/src/models/graph/biclique_cover.rs @@ -13,7 +13,7 @@ //! matrix of `G` (Monson, Pullman, Rees 1995), matching exact Boolean //! Matrix Factorization. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use crate::types::Min; @@ -28,12 +28,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Cover bipartite edges with k bicliques", - fields: &[ - FieldInfo { name: "left_size", type_name: "usize", description: "Vertices in left partition" }, - FieldInfo { name: "right_size", type_name: "usize", description: "Vertices in right partition" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Bipartite edges" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of bicliques" }, - ], + fields: BicliqueCoverCreateSpec::FIELDS, } } @@ -70,6 +65,43 @@ pub struct BicliqueCover { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BicliqueCoverCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Number of bicliques available to cover the edges. + k: usize, +} + +impl TryFrom for BicliqueCover { + type Error = String; + + fn try_from(spec: BicliqueCoverCreateSpec) -> Result { + for (edge_index, &(left_vertex, right_vertex)) in spec.biedges.iter().enumerate() { + if left_vertex >= spec.left { + return Err(format!( + "biedges[{edge_index}] left vertex {left_vertex} is out of bounds for left partition size {}", + spec.left + )); + } + if right_vertex >= spec.right { + return Err(format!( + "biedges[{edge_index}] right vertex {right_vertex} is out of bounds for right partition size {}", + spec.right + )); + } + } + + let graph = BipartiteGraph::new(spec.left, spec.right, spec.biedges); + Ok(Self::new(graph, spec.k)) + } +} + impl BicliqueCover { /// Create a new Biclique Cover problem. /// @@ -290,7 +322,7 @@ impl Problem for BicliqueCover { } crate::declare_variants! { - default BicliqueCover => "2^(num_vertices * rank)", + default BicliqueCover => "2^(num_vertices * rank)" create BicliqueCoverCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index d923c2ce9..2b4d5949f 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -4,7 +4,7 @@ //! adding some subset of the potential edges can make the graph biconnected //! without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,11 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Add weighted potential edges to make a graph biconnected within budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "potential_weights", type_name: "Vec<(usize, usize, W)>", description: "Potential edges with augmentation weights" }, - FieldInfo { name: "budget", type_name: "W::Sum", description: "Maximum total augmentation weight B" }, - ], + fields: BiconnectivityAugmentationCreateSpec::FIELDS, } } @@ -54,6 +50,65 @@ where budget: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BiconnectivityAugmentationCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + potential_weights: Vec<(usize, usize, i32)>, + budget: i64, +} + +impl TryFrom + for BiconnectivityAugmentation +{ + type Error = String; + fn try_from(spec: BiconnectivityAugmentationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + let graph = SimpleGraph::new(count, spec.graph); + let mut seen = BTreeSet::new(); + for &(u, v, _) in &spec.potential_weights { + if u >= count || v >= count { + return Err("potential edge endpoint is out of bounds".into()); + } + if u == v { + return Err("potential edge is a self-loop".into()); + } + let edge = normalize_edge(u, v); + if graph.has_edge(edge.0, edge.1) { + return Err("potential edge already exists in graph".into()); + } + if !seen.insert(edge) { + return Err("duplicate potential edge".into()); + } + } + Ok(Self { + graph, + potential_weights: spec.potential_weights, + budget: spec.budget, + }) + } +} + impl BiconnectivityAugmentation { /// Create a new biconnectivity augmentation instance. /// @@ -255,7 +310,7 @@ fn is_biconnected(graph: &G) -> bool { } crate::declare_variants! { - default BiconnectivityAugmentation => "2^num_potential_edges", + default BiconnectivityAugmentation => "2^num_potential_edges" create BiconnectivityAugmentationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 3badad9cb..1dfbd5824 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle //! minimizing the maximum selected edge weight. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z" }, - ], + fields: BottleneckTravelingSalesmanCreateSpec::FIELDS, } } @@ -31,6 +28,62 @@ pub struct BottleneckTravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BottleneckTravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for BottleneckTravelingSalesman { + type Error = String; + + fn try_from(spec: BottleneckTravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BottleneckTravelingSalesman { /// Create a BottleneckTravelingSalesman problem from a graph with edge weights. pub fn new(graph: SimpleGraph, edge_weights: Vec) -> Self { @@ -157,7 +210,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "num_vertices^2 * 2^num_vertices", + default BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 32f229a1a..c9d0c09d3 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -4,7 +4,7 @@ //! weighted graph can be partitioned into at most `K` connected components, each //! of total weight at most `B`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -23,12 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Partition vertices into at most K connected components, each of total weight at most B", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "max_components", type_name: "usize", description: "Upper bound K on the number of connected components" }, - FieldInfo { name: "max_weight", type_name: "W::Sum", description: "Upper bound B on the total weight of each component" }, - ], + fields: BoundedComponentSpanningForestCreateSpec::FIELDS, } } @@ -50,6 +45,44 @@ pub struct BoundedComponentSpanningForest { max_weight: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedComponentSpanningForestCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w(v) for each vertex v in V. + weights: Vec, + /// Upper bound K on the number of connected components. + k: usize, + /// Upper bound B on the total weight of each component. + max_weight: i64, +} + +impl TryFrom + for BoundedComponentSpanningForest +{ + type Error = String; + + fn try_from(spec: BoundedComponentSpanningForestCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.weights.iter().any(|&weight| weight < 0) { + return Err("weights must be nonnegative".to_string()); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + if spec.max_weight <= 0 { + return Err("max_weight must be positive".to_string()); + } + Ok(Self::new(spec.graph, spec.weights, spec.k, spec.max_weight)) + } +} + impl BoundedComponentSpanningForest { /// Create a new bounded-component spanning forest instance. pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { @@ -230,7 +263,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "3^num_vertices", + default BoundedComponentSpanningForest => "3^num_vertices" create BoundedComponentSpanningForestCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 217e203b6..42b5561a8 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -4,7 +4,7 @@ //! bound D, determine whether G has a spanning tree with total weight at most B //! and diameter (longest shortest path in edges) at most D. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -24,12 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Does G have a spanning tree with total weight <= B and diameter <= D?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound B on total tree weight" }, - FieldInfo { name: "diameter_bound", type_name: "usize", description: "Upper bound D on tree diameter (in edges)" }, - ], + fields: BoundedDiameterSpanningTreeCreateSpec::FIELDS, } } @@ -80,6 +75,78 @@ pub struct BoundedDiameterSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedDiameterSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + weight_bound: i64, + diameter_bound: usize, +} + +impl TryFrom + for BoundedDiameterSpanningTree +{ + type Error = String; + + fn try_from(spec: BoundedDiameterSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + if edge_weights.iter().any(|&weight| weight <= 0) { + return Err("edge_weights must be positive".to_string()); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + if spec.diameter_bound == 0 { + return Err("diameter_bound must be at least 1".to_string()); + } + Ok(Self::new( + graph, + edge_weights, + spec.weight_bound, + spec.diameter_bound, + )) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BoundedDiameterSpanningTree { /// Create a new Bounded Diameter Spanning Tree instance. /// @@ -280,7 +347,7 @@ where } crate::declare_variants! { - default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices", + default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices" create BoundedDiameterSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index d565fa123..b48a1f1af 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -3,7 +3,7 @@ //! The problem asks whether an undirected graph contains pairwise //! vertex-disjoint paths connecting a prescribed collection of terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find pairwise vertex-disjoint paths connecting given terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminal_pairs", type_name: "Vec<(usize, usize)>", description: "Disjoint terminal pairs (s_i, t_i)" }, - ], + fields: DisjointConnectingPathsCreateSpec::FIELDS, } } @@ -39,6 +36,62 @@ pub struct DisjointConnectingPaths { terminal_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct DisjointConnectingPathsCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "edge-list")] + terminal_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for DisjointConnectingPaths { + type Error = String; + fn try_from(spec: DisjointConnectingPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.terminal_pairs.is_empty() { + return Err("terminal_pairs must contain at least one pair".into()); + } + let mut used = vec![false; count]; + for &(source, sink) in &spec.terminal_pairs { + if source >= count || sink >= count { + return Err("terminal pair endpoint is out of bounds".into()); + } + if source == sink { + return Err("terminal pair endpoints must be distinct".into()); + } + if used[source] || used[sink] { + return Err("terminal vertices must be pairwise disjoint".into()); + } + used[source] = true; + used[sink] = true; + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + terminal_pairs: spec.terminal_pairs, + }) + } +} + impl DisjointConnectingPaths { /// Create a new Disjoint Connecting Paths instance. /// @@ -243,7 +296,7 @@ fn is_valid_disjoint_connecting_paths( } crate::declare_variants! { - default DisjointConnectingPaths => "2^num_edges", + default DisjointConnectingPaths => "2^num_edges" create DisjointConnectingPathsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index ef7252aae..156bf995a 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Determine whether Player 1 has a forced blue path between two terminals", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The source terminal s" }, - FieldInfo { name: "target", type_name: "usize", description: "The target terminal t" }, - ], + fields: GeneralizedHexCreateSpec::FIELDS, } } @@ -43,6 +39,40 @@ pub struct GeneralizedHex { target: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GeneralizedHexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// The source terminal s. + source: usize, + /// The target terminal t. + sink: usize, +} + +impl TryFrom for GeneralizedHex { + type Error = String; + + fn try_from(spec: GeneralizedHexCreateSpec) -> Result { + let num_vertices = spec.graph.num_vertices(); + if spec.source >= num_vertices { + return Err(format!( + "source {} is outside graph with {num_vertices} vertices", + spec.source + )); + } + if spec.sink >= num_vertices { + return Err(format!( + "sink {} is outside graph with {num_vertices} vertices", + spec.sink + )); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + Ok(Self::new(spec.graph, spec.source, spec.sink)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ClaimState { Unclaimed, @@ -264,7 +294,7 @@ where } crate::declare_variants! { - default GeneralizedHex => "3^num_playable_vertices", + default GeneralizedHex => "3^num_playable_vertices" create GeneralizedHexCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index 893cb1da3..70e9f5eb4 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -3,7 +3,7 @@ //! Given a directed graph with overlapping bundle-capacity constraints on arcs, //! determine whether an integral flow can deliver a required amount to the sink. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a directed graph with overlapping bundle capacities", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G=(V,A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "bundles", type_name: "Vec>", description: "Bundles of arc indices covering A" }, - FieldInfo { name: "bundle_capacities", type_name: "Vec", description: "Capacity c_j for each bundle I_j" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowBundlesCreateSpec::FIELDS, } } @@ -46,6 +39,92 @@ pub struct IntegralFlowBundles { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowBundlesCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "semicolon-separated")] + bundles: Vec>, + #[create(codec = "comma-separated")] + bundle_capacities: Vec, + source: usize, + sink: usize, + requirement: u64, +} + +impl TryFrom for IntegralFlowBundles { + type Error = String; + fn try_from(spec: IntegralFlowBundlesCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + if spec.bundles.len() != spec.bundle_capacities.len() { + return Err("bundles length must match bundle_capacities length".into()); + } + if spec.requirement == 0 { + return Err("requirement must be positive".into()); + } + let mut covered = vec![false; spec.arcs.len()]; + let mut upper = vec![u64::MAX; spec.arcs.len()]; + for (i, (bundle, &capacity)) in spec.bundles.iter().zip(&spec.bundle_capacities).enumerate() + { + if capacity == 0 { + return Err(format!("bundle capacity {i} must be positive")); + } + let mut seen = BTreeSet::new(); + for &arc in bundle { + if arc >= spec.arcs.len() { + return Err(format!("bundle {i} arc is out of range")); + } + if !seen.insert(arc) { + return Err(format!("bundle {i} contains duplicate arc")); + } + covered[arc] = true; + upper[arc] = upper[arc].min(capacity); + } + } + for (arc, &is_covered) in covered.iter().enumerate() { + if !is_covered { + return Err(format!("arc {arc} must belong to a bundle")); + } + if usize::try_from(upper[arc]) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err(format!("arc {arc} upper bound is too large")); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + bundles: spec.bundles, + bundle_capacities: spec.bundle_capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowBundles { /// Create a new Integral Flow with Bundles instance. pub fn new( @@ -267,7 +346,7 @@ impl Problem for IntegralFlowBundles { } crate::declare_variants! { - default IntegralFlowBundles => "2^num_arcs", + default IntegralFlowBundles => "2^num_arcs" create IntegralFlowBundlesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 0798cd834..29a0c5001 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -4,7 +4,7 @@ //! that must carry equal flow, determine whether an integral flow meeting the //! required sink inflow exists. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility with arc-pair equality constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - FieldInfo { name: "homologous_pairs", type_name: "Vec<(usize, usize)>", description: "Arc-index pairs (a, a') with f(a) = f(a')" }, - ], + fields: IntegralFlowHomologousArcsCreateSpec::FIELDS, } } @@ -51,6 +44,70 @@ pub struct IntegralFlowHomologousArcs { homologous_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowHomologousArcsCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Option>, + source: usize, + sink: usize, + requirement: u64, + #[create(codec = "equality-pair-list")] + homologous_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for IntegralFlowHomologousArcs { + type Error = String; + fn try_from(spec: IntegralFlowHomologousArcsCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + if capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + for &(a, b) in &spec.homologous_pairs { + if a >= spec.arcs.len() || b >= spec.arcs.len() { + return Err("homologous pair arc index is out of range".into()); + } + } + for &c in &capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + capacities, + source: spec.source, + sink: spec.sink, + requirement: spec.requirement, + homologous_pairs: spec.homologous_pairs, + }) + } +} + impl IntegralFlowHomologousArcs { pub fn new( graph: DirectedGraph, @@ -208,7 +265,7 @@ impl Problem for IntegralFlowHomologousArcs { } crate::declare_variants! { - default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs", + default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs" create IntegralFlowHomologousArcsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index d620d4b24..f8ed72103 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -4,7 +4,7 @@ //! non-terminals, and a sink demand, determine whether there exists an //! integral flow satisfying multiplier-scaled conservation. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,14 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a directed graph with multiplier-scaled conservation at non-terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "multipliers", type_name: "Vec", description: "Vertex multipliers h(v) in vertex order; source/sink entries are ignored" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Arc capacities c(a) in graph arc order" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowWithMultipliersCreateSpec::FIELDS, } } @@ -45,6 +38,75 @@ pub struct IntegralFlowWithMultipliers { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowWithMultipliersCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Vec, + source: usize, + sink: usize, + #[create(codec = "comma-separated")] + multipliers: Vec, + requirement: u64, +} + +impl TryFrom for IntegralFlowWithMultipliers { + type Error = String; + fn try_from(spec: IntegralFlowWithMultipliersCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.multipliers.len() != count { + return Err("multipliers length must match num_vertices".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + for (v, &m) in spec.multipliers.iter().enumerate() { + if v != spec.source && v != spec.sink && m == 0 { + return Err("non-terminal multipliers must be positive".into()); + } + } + for &c in &spec.capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + multipliers: spec.multipliers, + capacities: spec.capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowWithMultipliers { pub fn new( graph: DirectedGraph, @@ -214,7 +276,7 @@ impl Problem for IntegralFlowWithMultipliers { } crate::declare_variants! { - default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs", + default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs" create IntegralFlowWithMultipliersCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 94fa7e788..71c8d2e78 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -3,7 +3,7 @@ //! KClique is the decision version of Clique: determine whether a graph //! contains a clique of size at least `k`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"])], module_path: module_path!(), description: "Determine whether a graph contains a clique of size at least k", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Minimum clique size threshold" }, - ], + fields: KCliqueCreateSpec::FIELDS, } } @@ -34,6 +31,50 @@ pub struct KClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KCliqueCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + k: usize, +} + +impl TryFrom for KClique { + type Error = String; + fn try_from(spec: KCliqueCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.k == 0 { + return Err("k must be positive".into()); + } + if spec.k > count { + return Err("k must be <= graph num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + k: spec.k, + }) + } +} + impl KClique { /// Create a new k-Clique problem instance. pub fn new(graph: G, k: usize) -> Self { @@ -136,7 +177,7 @@ fn is_kclique_config(graph: &G, config: &[usize], k: usize) -> bool { } crate::declare_variants! { - default KClique => "1.1996^num_vertices", + default KClique => "1.1996^num_vertices" create KCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9e810dfc9..cbb91175a 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -3,7 +3,7 @@ //! The K-Coloring problem asks whether a graph can be colored with K colors //! such that no two adjacent vertices have the same color. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::{KValue, VariantParam, K2, K3, K4, K5, KN}; @@ -20,9 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find valid k-coloring of a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - ], + fields: RuntimeKColoringCreateSpec::FIELDS, } } @@ -68,6 +66,81 @@ pub struct KColoring { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct FixedKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuntimeKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Runtime color count. + k: usize, +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = num_vertices.unwrap_or(inferred); + if count < inferred { + return Err(format!( + "num_vertices {count} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(count, edges)) +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: FixedKColoringCreateSpec) -> Result { + let num_colors = K::K.ok_or("runtime KColoring requires k")?; + Ok(Self { + graph: simple_graph_from_create(spec.graph, spec.num_vertices)?, + num_colors, + _phantom: std::marker::PhantomData, + }) + } +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: RuntimeKColoringCreateSpec) -> Result { + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::with_k( + simple_graph_from_create(spec.graph, spec.num_vertices)?, + spec.k, + )) + } +} + fn default_num_colors() -> usize { K::K.unwrap_or(0) } @@ -201,12 +274,12 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", - KColoring => "num_vertices + num_edges", - KColoring => "1.3289^num_vertices", - KColoring => "1.7159^num_vertices", + default KColoring => "2^num_vertices" create RuntimeKColoringCreateSpec, + KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec, + KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec, + KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec, // Best known: O*((2-ε)^n) for some ε > 0 (Zamir 2021), concrete ε unknown - KColoring => "2^num_vertices", + KColoring => "2^num_vertices" create FixedKColoringCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index f26c26fcb..d008f6b61 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -3,7 +3,7 @@ //! Given a weighted graph, determine whether it contains `k` distinct spanning //! trees whose total weights are all at most a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -19,12 +19,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], module_path: module_path!(), description: "Do there exist k distinct spanning trees with total weight at most B?", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w(e) for each edge in E" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of distinct spanning trees required" }, - FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on each spanning tree weight" }, - ], + fields: KthBestSpanningTreeCreateSpec::FIELDS, } } @@ -46,6 +41,65 @@ pub struct KthBestSpanningTree { bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthBestSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + bound: i64, +} + +impl TryFrom for KthBestSpanningTree { + type Error = String; + + fn try_from(spec: KthBestSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + weights.len(), + graph.num_edges() + )); + } + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::new(graph, weights, spec.k, spec.bound)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl KthBestSpanningTree { /// Create a new KthBestSpanningTree instance. /// @@ -240,7 +294,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^(num_edges * k)", + default KthBestSpanningTree => "2^(num_edges * k)" create KthBestSpanningTreeCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 93e97073c..0fd0afd71 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -3,7 +3,7 @@ //! The problem maximizes the number of internally vertex-disjoint `s-t` paths, //! each using at most `K` edges, over up to `max_paths` path slots. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Max; @@ -20,13 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Maximize the number of internally vertex-disjoint s-t paths of length at most K", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The shared source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "The shared sink vertex t" }, - FieldInfo { name: "max_paths", type_name: "usize", description: "Upper bound on the number of path slots" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum path length K in edges" }, - ], + fields: LengthBoundedDisjointPathsCreateSpec::FIELDS, } } @@ -48,6 +42,72 @@ pub struct LengthBoundedDisjointPaths { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LengthBoundedDisjointPathsCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Shared source vertex. + source: usize, + /// Shared sink vertex. + sink: usize, + /// Maximum path length in edges. + max_length: usize, +} + +impl TryFrom for LengthBoundedDisjointPaths { + type Error = String; + + fn try_from(spec: LengthBoundedDisjointPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + if spec.source >= num_vertices || spec.sink >= num_vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.max_length == 0 { + return Err("max_length must be positive".to_string()); + } + + let graph = SimpleGraph::new(num_vertices, spec.graph); + let max_paths = graph + .neighbors(spec.source) + .len() + .min(graph.neighbors(spec.sink).len()); + Ok(Self { + graph, + source: spec.source, + sink: spec.sink, + max_paths, + max_length: spec.max_length, + }) + } +} + impl LengthBoundedDisjointPaths { /// Create a new Length-Bounded Disjoint Paths instance. /// @@ -301,7 +361,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^(max_paths * num_vertices)", + default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)" create LengthBoundedDisjointPathsCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 735d11d00..1c9988599 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -3,7 +3,7 @@ //! The Longest Circuit problem asks for a simple circuit in a graph //! that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple circuit in a graph that maximizes total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> Z_(> 0)" }, - ], + fields: LongestCircuitCreateSpec::FIELDS, } } @@ -48,6 +45,65 @@ pub struct LongestCircuit { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCircuitCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for LongestCircuit { + type Error = String; + + fn try_from(spec: LongestCircuitCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if edge_lengths.iter().any(|&length| length <= 0) { + return Err("edge_weights must be positive".to_string()); + } + Ok(Self::new(graph, edge_lengths)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl LongestCircuit { /// Create a new LongestCircuit instance. /// @@ -256,7 +312,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices * num_vertices^2", + default LongestCircuit => "2^num_vertices * num_vertices^2" create LongestCircuitCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index fd70dbeb3..74fd5f9a1 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -3,7 +3,7 @@ //! The Longest Path problem asks for a simple path between two distinguished //! vertices that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -22,12 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple s-t path of maximum total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - ], + fields: LongestPathI32CreateSpec::FIELDS, } } @@ -53,6 +48,63 @@ pub struct LongestPath { target_vertex: usize, } +macro_rules! longest_path_create_spec { + ($name:ident,$weight:ty) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_lengths: Vec<$weight>, + source_vertex: usize, + target_vertex: usize, + } + impl TryFrom<$name> for LongestPath { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.edge_lengths.len() != spec.graph.len() { + return Err("edge_lengths length must match graph edge count".into()); + } + if spec.edge_lengths.iter().any(|v| v.to_sum() <= 0) { + return Err("edge lengths must be positive".into()); + } + if spec.source_vertex >= count || spec.target_vertex >= count { + return Err("source_vertex and target_vertex must be valid vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + edge_lengths: spec.edge_lengths, + source_vertex: spec.source_vertex, + target_vertex: spec.target_vertex, + }) + } + } + }; +} +longest_path_create_spec!(LongestPathI32CreateSpec, i32); +longest_path_create_spec!(LongestPathOneCreateSpec, One); + impl LongestPath { fn assert_positive_edge_lengths(edge_lengths: &[W]) { let zero = W::Sum::zero(); @@ -253,8 +305,8 @@ fn is_simple_st_path( } crate::declare_variants! { - default LongestPath => "num_vertices * 2^num_vertices", - LongestPath => "num_vertices * 2^num_vertices", + default LongestPath => "num_vertices * 2^num_vertices" create LongestPathI32CreateSpec, + LongestPath => "num_vertices * 2^num_vertices" create LongestPathOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0dba64bbc..edd4354ee 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -3,7 +3,7 @@ //! The Maximum Cut problem asks for a partition of vertices into two sets //! that maximizes the total weight of edges crossing the partition. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight cut in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The graph with edge weights" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaxCutI32CreateSpec::FIELDS, } } @@ -77,6 +74,67 @@ pub struct MaxCut { edge_weights: Vec, } +macro_rules! max_cut_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + } + + impl TryFrom<$name> for MaxCut { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } + } + }; +} + +max_cut_create_spec!(MaxCutI32CreateSpec, i32, 1); +max_cut_create_spec!(MaxCutOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaxCut { /// Create a MaxCut problem from a graph with specified edge weights. /// @@ -209,8 +267,8 @@ where } crate::declare_variants! { - default MaxCut => "2^(2.372 * num_vertices / 3)", - MaxCut => "2^(0.7907 * num_vertices)", + default MaxCut => "2^(2.372 * num_vertices / 3)" create MaxCutI32CreateSpec, + MaxCut => "2^(0.7907 * num_vertices)" create MaxCutOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index b08c37d4b..f157fac2c 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -3,7 +3,7 @@ //! The Maximal Independent Set problem asks for an independent set that //! cannot be extended by adding any other vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight maximal independent set", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximalISCreateSpec::FIELDS, } } @@ -63,6 +60,28 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximalISCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom for MaximalIS { + type Error = String; + fn try_from(spec: MaximalISCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximalIS { /// Create a Maximal Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -223,7 +242,7 @@ pub(crate) fn is_maximal_independent_set(graph: &G, selected: &[bool]) } crate::declare_variants! { - default MaximalIS => "3^(num_vertices / 3)", + default MaximalIS => "3^(num_vertices / 3)" create MaximalISCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 849bef38a..a507783ac 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -3,7 +3,7 @@ //! The MaximumClique problem asks for a maximum weight subset of vertices //! such that all vertices in the subset are pairwise adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight clique in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumCliqueCreateSpec::::FIELDS, } } @@ -66,6 +63,28 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCliqueCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> for MaximumClique { + type Error = String; + fn try_from(spec: MaximumCliqueCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximumClique { /// Create a MaximumClique problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -165,8 +184,8 @@ fn is_clique_config(graph: &G, config: &[usize]) -> bool { } crate::declare_variants! { - MaximumClique => "1.1996^num_vertices", - default MaximumClique => "1.1996^num_vertices", + MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec, + default MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 5c691e4e0..a22e22a8c 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -8,7 +8,7 @@ //! For k = 1 the problem degenerates to [`MaximumIndependentSet`]; for larger //! k it is the maximum (k-1)-dependent set / co-k-plex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -28,11 +28,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum-weight vertex subset whose induced subgraph has maximum degree at most k-1", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound_k", type_name: "usize", description: "Co-k-plex parameter k >= 1; selected-vertex induced degree must be at most k-1" }, - ], + fields: MaximumCoKPlexCreateSpec::::FIELDS, } } @@ -91,6 +87,36 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCoKPlexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, + /// Co-k-plex parameter k >= 1. + k: usize, +} + +impl TryFrom> + for MaximumCoKPlex +{ + type Error = String; + + fn try_from(spec: MaximumCoKPlexCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + } +} + impl MaximumCoKPlex { /// Create an instance with an explicit runtime `k`. /// @@ -224,8 +250,8 @@ fn is_co_k_plex_config(graph: &G, config: &[usize], bound_k: usize) -> } crate::declare_variants! { - default MaximumCoKPlex => "2^num_vertices", - MaximumCoKPlex => "2^num_vertices", + default MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, + MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index 9babdbebc..e4d814e4d 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -11,7 +11,7 @@ //! are allowed when `k` takes those values, with objective value 0 because no //! pair of selected vertices is induced. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -26,11 +26,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], module_path: module_path!(), description: "Select exactly k pairwise-adjacent vertices maximizing the total weight of induced clique edges", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights in graph edge order" }, - FieldInfo { name: "k", type_name: "usize", description: "Required clique size" }, - ], + fields: MaximumEdgeWeightedKCliqueCreateSpec::::FIELDS, } } @@ -77,6 +73,38 @@ pub struct MaximumEdgeWeightedKClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumEdgeWeightedKCliqueCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Required clique size. + k: usize, +} +impl TryFrom> for MaximumEdgeWeightedKClique +where + W: WeightElement + From, +{ + type Error = String; + fn try_from(spec: MaximumEdgeWeightedKCliqueCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if spec.k > spec.graph.num_vertices() { + return Err("k must not exceed the number of vertices".to_string()); + } + Ok(Self::new(spec.graph, edge_weights, spec.k)) + } +} + impl MaximumEdgeWeightedKClique { /// Create a new MaximumEdgeWeightedKClique instance. /// @@ -191,8 +219,8 @@ fn is_k_clique_config(graph: &SimpleGraph, config: &[usize], k: usize) -> bool { } crate::declare_variants! { - default MaximumEdgeWeightedKClique => "2^num_vertices", - MaximumEdgeWeightedKClique => "2^num_vertices", + default MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, + MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 9f7e72a08..9bf1b69cb 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -3,7 +3,7 @@ //! The Independent Set problem asks for a maximum weight subset of vertices //! such that no two vertices in the subset are adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight independent set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumIndependentSetSimpleOneCreateSpec::FIELDS, } } @@ -66,6 +63,138 @@ pub struct MaximumIndependentSet { weights: Vec, } +macro_rules! simple_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let weights = spec.weights.unwrap_or_else(|| vec![$one; count]); + if weights.len() != count { + return Err("weights length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + weights, + }) + } + } + }; +} +simple_mis_spec!(MaximumIndependentSetSimpleOneCreateSpec, One, One); +simple_mis_spec!(MaximumIndependentSetSimpleI32CreateSpec, i32, 1_i32); + +macro_rules! grid_mis_spec { + ($name:ident,$graph:ty,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(i32, i32)>, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet<$graph, $weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: <$graph>::new(spec.positions), + weights, + }) + } + } + }; +} +grid_mis_spec!( + MaximumIndependentSetKingsOneCreateSpec, + KingsSubgraph, + One, + One +); +grid_mis_spec!( + MaximumIndependentSetKingsI32CreateSpec, + KingsSubgraph, + i32, + 1_i32 +); +grid_mis_spec!( + MaximumIndependentSetTriangularI32CreateSpec, + TriangularSubgraph, + i32, + 1_i32 +); + +macro_rules! unit_disk_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(f64, f64)>, + radius: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + let radius = spec.radius.unwrap_or(1.0); + if !radius.is_finite() || radius < 0.0 { + return Err("radius must be finite and nonnegative".into()); + } + if spec + .positions + .iter() + .any(|&(x, y)| !x.is_finite() || !y.is_finite()) + { + return Err("positions must be finite".into()); + } + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: UnitDiskGraph::new(spec.positions, radius), + weights, + }) + } + } + }; +} +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskOneCreateSpec, One, One); +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskI32CreateSpec, i32, 1_i32); + impl MaximumIndependentSet { /// Create an Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -154,13 +283,13 @@ fn is_independent_set_config(graph: &G, config: &[usize]) -> bool { } crate::declare_variants! { - MaximumIndependentSet => "1.1996^num_vertices", - default MaximumIndependentSet => "1.1996^num_vertices", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", + MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleI32CreateSpec, + default MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleOneCreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsOneCreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetTriangularI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskI32CreateSpec, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskOneCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MaximumIndependentSet diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index f2c3d0719..d648fe2ac 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -3,7 +3,7 @@ //! The Maximum Matching problem asks for a maximum weight set of edges //! such that no two edges share a vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find maximum weight matching in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaximumMatchingCreateSpec::FIELDS, } } @@ -66,6 +63,62 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumMatchingCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for MaximumMatching { + type Error = String; + + fn try_from(spec: MaximumMatchingCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaximumMatching { /// Create a MaximumMatching problem from a graph with given edge weights. /// @@ -214,7 +267,7 @@ where } crate::declare_variants! { - default MaximumMatching => "num_vertices^3", + default MaximumMatching => "num_vertices^3" create MaximumMatchingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 5bf60ef53..a270a6cb9 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -3,10 +3,10 @@ //! The vertex p-center problem asks for K centers on vertices of a graph that //! minimize the maximum weighted distance from any vertex to its nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, WeightElement}; +use crate::types::{Min, One, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -21,12 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find K centers minimizing the maximum weighted distance from any vertex to its nearest center (vertex p-center)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinMaxMulticenterI32CreateSpec::FIELDS, } } @@ -69,6 +64,96 @@ pub struct MinMaxMulticenter { k: usize, } +macro_rules! min_max_multicenter_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + } + + impl TryFrom<$name> for MinMaxMulticenter { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + let zero = <$weight as WeightElement>::Sum::zero(); + if vertex_weights + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("weights must be non-negative".to_string()); + } + if edge_lengths + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("edge_weights must be non-negative".to_string()); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } + } + }; +} + +min_max_multicenter_create_spec!(MinMaxMulticenterI32CreateSpec, i32, 1); +min_max_multicenter_create_spec!(MinMaxMulticenterOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinMaxMulticenter { /// Create a MinMaxMulticenter problem. /// @@ -272,8 +357,8 @@ where } crate::declare_variants! { - default MinMaxMulticenter => "1.4969^num_vertices", - MinMaxMulticenter => "1.4969^num_vertices", + default MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterI32CreateSpec, + MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 04762cf3d..3de793ec8 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -8,7 +8,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -24,13 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight spanning tree with subtree capacity constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "root", type_name: "usize", description: "Root vertex" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Vertex requirements r: V -> R (root has 0)" }, - FieldInfo { name: "capacity", type_name: "W::Sum", description: "Subtree capacity bound" }, - ], + fields: MinimumCapacitatedSpanningTreeCreateSpec::FIELDS, } } @@ -67,6 +61,55 @@ pub struct MinimumCapacitatedSpanningTree { capacity: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCapacitatedSpanningTreeCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + weights: Option>, + /// Root vertex. + root: usize, + /// Vertex requirements. + requirements: Vec, + /// Subtree capacity bound. + capacity: i64, +} +impl TryFrom + for MinimumCapacitatedSpanningTree +{ + type Error = String; + fn try_from(spec: MinimumCapacitatedSpanningTreeCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + let weights = spec.weights.unwrap_or_else(|| vec![1; edges]); + if weights.len() != edges { + return Err(format!( + "weights has {} entries, expected {edges}", + weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if vertices < 2 { + return Err("graph must have at least two vertices".to_string()); + } + if spec.requirements.len() != vertices { + return Err(format!( + "requirements has {} entries, expected {vertices}", + spec.requirements.len() + )); + } + if spec.root >= vertices { + return Err("root is outside the graph".to_string()); + } + Ok(Self::new( + spec.graph, + weights, + spec.root, + spec.requirements, + spec.capacity, + )) + } +} + impl MinimumCapacitatedSpanningTree { /// Create a MinimumCapacitatedSpanningTree problem. /// @@ -323,7 +366,7 @@ where } crate::declare_variants! { - default MinimumCapacitatedSpanningTree => "2^num_edges", + default MinimumCapacitatedSpanningTree => "2^num_edges" create MinimumCapacitatedSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 6ebaa7af6..36e986e31 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -4,7 +4,7 @@ //! bounded-size sets (containing designated source and sink vertices) that //! minimizes total cut weight. From Garey & Johnson, A2 ND17. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,13 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-weight cut partitioning vertices into two bounded-size sets", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G = (V, E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z+" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s (must be in V1)" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t (must be in V2)" }, - FieldInfo { name: "size_bound", type_name: "usize", description: "Maximum size B for each partition set" }, - ], + fields: MinimumCutIntoBoundedSetsCreateSpec::FIELDS, } } @@ -75,6 +69,44 @@ pub struct MinimumCutIntoBoundedSets { size_bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCutIntoBoundedSetsCreateSpec { + /// The undirected graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Maximum size for each partition set. + size_bound: usize, +} +impl TryFrom for MinimumCutIntoBoundedSets { + type Error = String; + fn try_from(spec: MinimumCutIntoBoundedSetsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec.edge_weights.unwrap_or_else(|| vec![1; count]); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices || spec.source == spec.sink { + return Err("source and sink must be distinct valid graph vertices".to_string()); + } + Ok(Self::new( + spec.graph, + edge_weights, + spec.source, + spec.sink, + spec.size_bound, + )) + } +} + impl MinimumCutIntoBoundedSets { /// Create a new MinimumCutIntoBoundedSets problem. /// @@ -228,7 +260,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", + default MinimumCutIntoBoundedSets => "2^num_vertices" create MinimumCutIntoBoundedSetsCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index fbedd5e05..a780fdd7b 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -4,7 +4,7 @@ //! such that every vertex is either in the set or adjacent to a vertex in the set. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -23,10 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight dominating set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumDominatingSetCreateSpec::::FIELDS, } } @@ -62,6 +59,30 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDominatingSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> + for MinimumDominatingSet +{ + type Error = String; + fn try_from(spec: MinimumDominatingSetCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MinimumDominatingSet { /// Create a Dominating Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -165,8 +186,8 @@ where } crate::declare_variants! { - default MinimumDominatingSet => "1.4969^num_vertices", - MinimumDominatingSet => "1.4969^num_vertices", + default MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec, + MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet @@ -245,6 +266,14 @@ inventory::submit! { }, is_default: false, aliases: &[], + create_inputs: None, + construct_fn: |data| { + let problem_type = > as Problem>::problem_type(); + crate::registry::validate_direct_create_inputs(problem_type.fields, &data)?; + serde_json::from_value::>>(data) + .map(|problem| Box::new(problem) as Box) + .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string())) + }, factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs index c4a8dbdb0..2c3b62251 100644 --- a/src/models/graph/minimum_dummy_activities_pert.rs +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -7,7 +7,7 @@ //! resulting event network is acyclic and preserves exactly the same //! task-to-task reachability relation as the original DAG. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -22,13 +22,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a PERT event network for a precedence DAG minimizing dummy activities", - fields: &[ - FieldInfo { - name: "graph", - type_name: "DirectedGraph", - description: "The precedence DAG G=(V,A) whose vertices are tasks and arcs encode direct precedence constraints", - }, - ], + fields: MinimumDummyActivitiesPertCreateSpec::FIELDS, } } @@ -46,6 +40,33 @@ pub struct MinimumDummyActivitiesPert { graph: DirectedGraph, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDummyActivitiesPertCreateSpec { + /// Directed precedence arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated tasks. + num_vertices: Option, +} +impl TryFrom for MinimumDummyActivitiesPert { + type Error = String; + fn try_from(spec: MinimumDummyActivitiesPertCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for the provided arcs".into()); + } + Self::try_new(DirectedGraph::new(num_vertices, spec.arcs)) + } +} + impl MinimumDummyActivitiesPert { /// Fallible constructor used by CLI validation and deserialization. pub fn try_new(graph: DirectedGraph) -> Result { @@ -201,7 +222,7 @@ impl Problem for MinimumDummyActivitiesPert { } crate::declare_variants! { - default MinimumDummyActivitiesPert => "2^num_arcs", + default MinimumDummyActivitiesPert => "2^num_arcs" create MinimumDummyActivitiesPertCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index a69fa1aa6..14049d9e1 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -3,7 +3,7 @@ //! The Feedback Arc Set problem asks for a minimum-weight subset of arcs //! whose removal makes a directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight feedback arc set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w: A -> R" }, - ], + fields: MinimumFeedbackArcSetCreateSpec::FIELDS, } } @@ -65,6 +62,28 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackArcSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Arc weights; defaults to one per arc. + weights: Option>, +} +impl TryFrom for MinimumFeedbackArcSet { + type Error = String; + fn try_from(spec: MinimumFeedbackArcSetCreateSpec) -> Result { + let count = spec.graph.num_arcs(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackArcSet { /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -165,7 +184,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - default MinimumFeedbackArcSet => "2^num_vertices", + default MinimumFeedbackArcSet => "2^num_vertices" create MinimumFeedbackArcSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index fe6b277e4..cb1130bf6 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -3,7 +3,7 @@ //! The Feedback Vertex Set problem asks for a minimum weight subset of vertices //! whose removal makes the directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,10 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight feedback vertex set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumFeedbackVertexSetCreateSpec::FIELDS, } } @@ -59,6 +56,28 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackVertexSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Vertex weights; defaults to one per vertex. + weights: Option>, +} +impl TryFrom for MinimumFeedbackVertexSet { + type Error = String; + fn try_from(spec: MinimumFeedbackVertexSetCreateSpec) -> Result { + let count = spec.graph.num_vertices(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackVertexSet { /// Create a Feedback Vertex Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -153,7 +172,7 @@ where } crate::declare_variants! { - default MinimumFeedbackVertexSet => "1.9977^num_vertices", + default MinimumFeedbackVertexSet => "1.9977^num_vertices" create MinimumFeedbackVertexSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 010a27bb7..2a5009b4f 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -3,7 +3,7 @@ //! The Minimum Multiway Cut problem asks for a minimum weight set of edges //! whose removal disconnects all terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight set of edges whose removal disconnects all terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices that must be separated" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R (same order as graph.edges())" }, - ], + fields: MinimumMultiwayCutCreateSpec::FIELDS, } } @@ -52,6 +48,49 @@ pub struct MinimumMultiwayCut { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumMultiwayCutCreateSpec { + /// The undirected graph G=(V,E). + graph: SimpleGraph, + /// Terminal vertices that must be separated. + terminals: Vec, + /// Edge weights w: E -> R in graph edge order. + edge_weights: Vec, +} + +impl TryFrom for MinimumMultiwayCut { + type Error = String; + fn try_from(spec: MinimumMultiwayCutCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.terminals, spec.edge_weights)) + } +} + impl MinimumMultiwayCut { /// Create a MinimumMultiwayCut problem. /// @@ -188,7 +227,7 @@ where } crate::declare_variants! { - default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3", + default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3" create MinimumMultiwayCutCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index e631f0d7c..34d652945 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -3,7 +3,7 @@ //! The p-median problem asks for K facility locations (centers) on a graph //! that minimize the total weighted distance from all vertices to their nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -21,12 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find K centers minimizing total weighted distance (p-median problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinimumSumMulticenterCreateSpec::FIELDS, } } @@ -70,6 +65,76 @@ pub struct MinimumSumMulticenter { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSumMulticenterCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, +} + +impl TryFrom for MinimumSumMulticenter { + type Error = String; + + fn try_from(spec: MinimumSumMulticenterCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![1; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinimumSumMulticenter { /// Create a MinimumSumMulticenter problem. /// @@ -248,7 +313,7 @@ where } crate::declare_variants! { - default MinimumSumMulticenter => "2^num_vertices", + default MinimumSumMulticenter => "2^num_vertices" create MinimumSumMulticenterCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index f33b93134..e219c2d72 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -4,7 +4,7 @@ //! such that every edge has at least one endpoint in the subset. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -22,10 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight vertex cover in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumVertexCoverCreateSpec::::FIELDS, } } @@ -62,6 +59,33 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumVertexCoverCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Option>, +} + +impl TryFrom> + for MinimumVertexCover +{ + type Error = String; + fn try_from(spec: MinimumVertexCoverCreateSpec) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![W::default(); spec.graph.num_vertices()]); + if weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumVertexCover { /// Create a Vertex Covering problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -152,8 +176,8 @@ pub(crate) fn is_vertex_cover_config(graph: &G, config: &[usize]) -> b } crate::declare_variants! { - default MinimumVertexCover => "1.1996^num_vertices", - MinimumVertexCover => "1.1996^num_vertices", + default MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec, + MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec, } impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index af333f700..852372323 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -4,7 +4,7 @@ //! minimum-cost closed walk that traverses every directed arc in its prescribed //! direction and every undirected edge in at least one direction. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{DirectedGraph, MixedGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -24,11 +24,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-cost closed walk covering all arcs and edges in a mixed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Lengths for the directed arcs in A" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Lengths for the undirected edges in E" }, - ], + fields: MixedChinesePostmanI32CreateSpec::FIELDS, } } @@ -45,6 +41,81 @@ pub struct MixedChinesePostman> { edge_weights: Vec, } +macro_rules! mixed_chinese_postman_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Directed-arc lengths; defaults to one per arc. + #[create(codec = "comma-separated")] + arc_weights: Option>, + /// Undirected-edge lengths; defaults to one per edge. + #[create(codec = "comma-separated")] + edge_weights: Option>, + } + + impl TryFrom<$name> for MixedChinesePostman<$weight> { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + for (index, &(u, v)) in spec.arcs.iter().enumerate() { + if u >= num_vertices || v >= num_vertices { + return Err(format!( + "arc {index} endpoint is out of range for {num_vertices} vertices" + )); + } + } + let arc_weights = spec + .arc_weights + .unwrap_or_else(|| vec![$one; spec.arcs.len()]); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![$one; spec.graph.len()]); + MixedChinesePostman::try_new( + MixedGraph::new(num_vertices, spec.arcs, spec.graph), + arc_weights, + edge_weights, + ) + } + } + }; +} + +mixed_chinese_postman_create_spec!(MixedChinesePostmanI32CreateSpec, i32, 1_i32); +mixed_chinese_postman_create_spec!(MixedChinesePostmanOneCreateSpec, One, One); + impl> MixedChinesePostman { /// Create a new mixed Chinese postman instance. /// @@ -53,42 +124,44 @@ impl> MixedChinesePostman { /// Panics if the weight-vector lengths do not match the graph shape or if /// any weight is negative. pub fn new(graph: MixedGraph, arc_weights: Vec, edge_weights: Vec) -> Self { - assert_eq!( - arc_weights.len(), - graph.num_arcs(), - "arc_weights length must match num_arcs" - ); - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); + Self::try_new(graph, arc_weights, edge_weights) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + ) -> Result { + if arc_weights.len() != graph.num_arcs() { + return Err("arc_weights length must match num_arcs".to_string()); + } + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".to_string()); + } for (index, weight) in arc_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "arc weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("arc weight at index {index} must be nonnegative")); + } } for (index, weight) in edge_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "edge weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("edge weight at index {index} must be nonnegative")); + } } - Self { + Ok(Self { graph, arc_weights, edge_weights, - } + }) } /// Return the mixed graph. @@ -238,8 +311,8 @@ where } crate::declare_variants! { - default MixedChinesePostman => "2^num_edges * num_vertices^3", - MixedChinesePostman => "2^num_edges * num_vertices^3", + default MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanI32CreateSpec, + MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index c0e90cdba..f32796695 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -4,7 +4,7 @@ //! threshold, determine whether there exists a high-weight branching that //! picks at most one arc from each partition group. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -22,12 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a branching with partition constraints and weight at least K", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w(a) for each arc a in A" }, - FieldInfo { name: "partition", type_name: "Vec>", description: "Partition of arc indices; each arc index must appear in exactly one group" }, - FieldInfo { name: "threshold", type_name: "W::Sum", description: "Weight threshold K" }, - ], + fields: MultipleChoiceBranchingCreateSpec::FIELDS, } } @@ -48,6 +43,56 @@ pub struct MultipleChoiceBranching { threshold: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleChoiceBranchingCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc weights w(a) for each arc a in A. + weights: Vec, + /// Partition of arc indices; each arc must appear exactly once. + partition: Vec>, + /// Weight threshold K. + threshold: i64, +} + +impl TryFrom for MultipleChoiceBranching { + type Error = String; + fn try_from(spec: MultipleChoiceBranchingCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for arc endpoints".to_string()); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let num_arcs = graph.num_arcs(); + if spec.weights.len() != num_arcs { + return Err(format!( + "weights has {} entries, expected {num_arcs}", + spec.weights.len() + )); + } + if let Some(message) = partition_validation_error(&spec.partition, num_arcs) { + return Err(message); + } + Ok(Self::new( + graph, + spec.weights, + spec.partition, + spec.threshold, + )) + } +} + #[derive(Debug, Deserialize)] struct MultipleChoiceBranchingUnchecked { graph: DirectedGraph, @@ -294,7 +339,7 @@ fn is_valid_multiple_choice_branching( } crate::declare_variants! { - default MultipleChoiceBranching => "2^num_arcs", + default MultipleChoiceBranching => "2^num_arcs" create MultipleChoiceBranchingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index c54269d30..a3b6d00db 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -3,7 +3,7 @@ //! The Multiple Copy File Allocation problem asks for a placement of file copies //! on graph vertices that minimizes the combined storage and access cost. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Place file copies on graph vertices to minimize total storage plus access cost", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The network graph G=(V,E)" }, - FieldInfo { name: "usage", type_name: "Vec", description: "Usage frequencies u(v) for each vertex" }, - FieldInfo { name: "storage", type_name: "Vec", description: "Storage costs s(v) for placing a copy at each vertex" }, - ], + fields: MultipleCopyFileAllocationCreateSpec::FIELDS, } } @@ -49,6 +45,58 @@ pub struct MultipleCopyFileAllocation { storage: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleCopyFileAllocationCreateSpec { + /// Network graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Usage frequency per vertex. + #[create(codec = "comma-separated")] + usage: Vec, + /// Storage cost per vertex. + #[create(codec = "comma-separated")] + storage: Vec, +} + +impl TryFrom for MultipleCopyFileAllocation { + type Error = String; + fn try_from(spec: MultipleCopyFileAllocationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.usage.len() != count { + return Err("usage length must match num_vertices".into()); + } + if spec.storage.len() != count { + return Err("storage length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + usage: spec.usage, + storage: spec.storage, + }) + } +} + impl MultipleCopyFileAllocation { /// Create a new Multiple Copy File Allocation instance. pub fn new(graph: SimpleGraph, usage: Vec, storage: Vec) -> Self { @@ -201,7 +249,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "2^num_vertices", + default MultipleCopyFileAllocation => "2^num_vertices" create MultipleCopyFileAllocationCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/partial_feedback_edge_set.rs b/src/models/graph/partial_feedback_edge_set.rs index 5806cd534..ef988d001 100644 --- a/src/models/graph/partial_feedback_edge_set.rs +++ b/src/models/graph/partial_feedback_edge_set.rs @@ -3,7 +3,7 @@ //! The Partial Feedback Edge Set problem asks whether removing at most `K` //! edges can hit every cycle of length at most `L`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,11 +20,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Remove at most K edges so that every cycle of length at most L is hit", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number K of edges that may be removed" }, - FieldInfo { name: "max_cycle_length", type_name: "usize", description: "Cycle length bound L; every cycle with length at most L must be hit" }, - ], + fields: PartialFeedbackEdgeSetCreateSpec::FIELDS, } } @@ -46,6 +42,23 @@ pub struct PartialFeedbackEdgeSet { max_cycle_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartialFeedbackEdgeSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Maximum number K of edges that may be removed. + budget: usize, + /// Cycle length bound L. + max_cycle_length: usize, +} + +impl TryFrom for PartialFeedbackEdgeSet { + type Error = String; + fn try_from(spec: PartialFeedbackEdgeSetCreateSpec) -> Result { + Ok(Self::new(spec.graph, spec.budget, spec.max_cycle_length)) + } +} + impl PartialFeedbackEdgeSet { /// Create a new Partial Feedback Edge Set instance. pub fn new(graph: G, budget: usize, max_cycle_length: usize) -> Self { @@ -242,7 +255,7 @@ fn normalize_edge(u: usize, v: usize) -> (usize, usize) { } crate::declare_variants! { - default PartialFeedbackEdgeSet => "2^num_edges", + default PartialFeedbackEdgeSet => "2^num_edges" create PartialFeedbackEdgeSetCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index 373598e22..8ad111ff2 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -6,7 +6,7 @@ //! capacities are respected and the total delivered flow reaches the required //! threshold. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,14 +20,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Integral flow feasibility on a prescribed collection of directed s-t paths", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "paths", type_name: "Vec>", description: "Prescribed directed s-t paths as arc-index sequences" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required total flow R" }, - ], + fields: PathConstrainedNetworkFlowCreateSpec::FIELDS, } } @@ -49,6 +42,64 @@ pub struct PathConstrainedNetworkFlow { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PathConstrainedNetworkFlowCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc capacities; defaults to one per arc. + #[create(codec = "comma-separated")] + capacities: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Prescribed paths as arc-index sequences. + #[create(codec = "semicolon-separated")] + paths: Vec>, + /// Required total flow. + requirement: u64, +} + +impl TryFrom for PathConstrainedNetworkFlow { + type Error = String; + + fn try_from(spec: PathConstrainedNetworkFlowCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.paths.is_empty() { + return Err("paths must be non-empty".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let graph = DirectedGraph::new(num_vertices, spec.arcs); + Self::try_new( + graph, + capacities, + spec.source, + spec.sink, + spec.paths, + spec.requirement, + ) + } +} + impl PathConstrainedNetworkFlow { /// Create a new Path-Constrained Network Flow instance. /// @@ -66,38 +117,59 @@ impl PathConstrainedNetworkFlow { paths: Vec>, requirement: u64, ) -> Self { + Self::try_new(graph, capacities, source, sink, paths, requirement) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: u64, + ) -> Result { let num_vertices = graph.num_vertices(); - assert_eq!( - capacities.len(), - graph.num_arcs(), - "capacities length must match graph num_arcs" - ); - assert!( - source < num_vertices, - "source ({source}) >= num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) >= num_vertices ({num_vertices})" - ); - assert_ne!(source, sink, "source and sink must be distinct"); - - for path in &paths { - Self::assert_valid_path(&graph, path, source, sink); + if capacities.len() != graph.num_arcs() { + return Err("capacities length must match graph num_arcs".to_string()); + } + if source >= num_vertices { + return Err(format!( + "source ({source}) >= num_vertices ({num_vertices})" + )); + } + if sink >= num_vertices { + return Err(format!("sink ({sink}) >= num_vertices ({num_vertices})")); + } + if source == sink { + return Err("source and sink must be distinct".to_string()); + } + + for (index, path) in paths.iter().enumerate() { + Self::validate_path(&graph, path, source, sink) + .map_err(|message| format!("path {index}: {message}"))?; } - Self { + Ok(Self { graph, capacities, source, sink, paths, requirement, - } + }) } - fn assert_valid_path(graph: &DirectedGraph, path: &[usize], source: usize, sink: usize) { - assert!(!path.is_empty(), "prescribed paths must be non-empty"); + fn validate_path( + graph: &DirectedGraph, + path: &[usize], + source: usize, + sink: usize, + ) -> Result<(), String> { + if path.is_empty() { + return Err("prescribed paths must be non-empty".to_string()); + } let arcs = graph.arcs(); let mut visited_vertices = HashSet::from([source]); @@ -106,22 +178,21 @@ impl PathConstrainedNetworkFlow { for &arc_idx in path { let &(tail, head) = arcs .get(arc_idx) - .unwrap_or_else(|| panic!("path arc index {arc_idx} out of bounds")); - assert_eq!( - tail, current, - "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" - ); - assert!( - visited_vertices.insert(head), - "prescribed path repeats vertex {head}, so it is not a simple path" - ); + .ok_or_else(|| format!("arc index {arc_idx} out of bounds"))?; + if tail != current { + return Err(format!( + "not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" + )); + } + if !visited_vertices.insert(head) { + return Err(format!("repeats vertex {head}, so it is not a simple path")); + } current = head; } - - assert_eq!( - current, sink, - "prescribed path must end at sink {sink}, ended at {current}" - ); + if current != sink { + return Err(format!("must end at sink {sink}, ended at {current}")); + } + Ok(()) } fn path_bottleneck(&self, path: &[usize]) -> u64 { @@ -235,7 +306,7 @@ impl Problem for PathConstrainedNetworkFlow { } crate::declare_variants! { - default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths", + default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths" create PathConstrainedNetworkFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index d44281dd0..e970a4aa8 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -24,7 +24,7 @@ //! - Earlier conference version, RECOMB 2012, LNBI 7262, pp. 287--301. //! -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -44,13 +44,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a forest minimizing omitted-prize plus edge-cost plus omega times the number of tree components", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying network G=(V,E)" }, - FieldInfo { name: "vertex_prizes", type_name: "Vec", description: "Nonnegative vertex prizes p: V -> R_{>=0}" }, - FieldInfo { name: "edge_costs", type_name: "Vec", description: "Nonnegative edge costs c: E -> R_{>=0} in graph.edges() order" }, - FieldInfo { name: "beta", type_name: "W", description: "Tradeoff coefficient beta >= 0 on the omitted-prize term" }, - FieldInfo { name: "omega", type_name: "W", description: "Per-component penalty omega >= 0 on the number of tree components" }, - ], + fields: PrizeCollectingSteinerForestI32CreateSpec::FIELDS, } } @@ -110,6 +104,89 @@ pub struct PrizeCollectingSteinerForest { omega: W, } +macro_rules! prize_collecting_steiner_forest_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + vertex_prizes: Option>, + #[create(codec = "comma-separated")] + edge_costs: Option>, + beta: $weight, + omega: $weight, + } + + impl TryFrom<$name> for PrizeCollectingSteinerForest { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_prizes = spec + .vertex_prizes + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_prizes.len() != graph.num_vertices() { + return Err(format!( + "vertex_prizes has length {}, expected {}", + vertex_prizes.len(), + graph.num_vertices() + )); + } + let edge_costs = spec + .edge_costs + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_costs.len() != graph.num_edges() { + return Err(format!( + "edge_costs has length {}, expected {}", + edge_costs.len(), + graph.num_edges() + )); + } + Ok(Self::new( + graph, + vertex_prizes, + edge_costs, + spec.beta, + spec.omega, + )) + } + } + }; +} + +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestI32CreateSpec, i32, 1); +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestF64CreateSpec, f64, 1.0); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl PrizeCollectingSteinerForest { /// Create a new Prize-Collecting Steiner Forest instance. /// @@ -306,8 +383,8 @@ fn forest_components(graph: &G, config: &[usize]) -> Option { } crate::declare_variants! { - default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", - PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", + default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestI32CreateSpec, + PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 164eccdc0..bb2f5c5b2 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -3,7 +3,7 @@ //! The Rural Postman problem asks for a minimum-cost circuit in a graph //! that includes each edge in a required subset E'. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,11 +22,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a minimum-cost circuit covering all required edges (Rural Postman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge lengths l(e) for each e in E" }, - FieldInfo { name: "required_edges", type_name: "Vec", description: "Edge indices of the required subset E' ⊆ E" }, - ], + fields: RuralPostmanCreateSpec::FIELDS, } } @@ -65,6 +61,71 @@ pub struct RuralPostman { required_edges: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuralPostmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + #[create(codec = "comma-separated")] + required_edges: Vec, +} + +impl TryFrom for RuralPostman { + type Error = String; + + fn try_from(spec: RuralPostmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if let Some(&edge) = spec + .required_edges + .iter() + .find(|&&edge| edge >= graph.num_edges()) + { + return Err(format!("required edge index {edge} is out of bounds")); + } + Ok(Self::new(graph, edge_lengths, spec.required_edges)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl RuralPostman { /// Create a new RuralPostman problem. /// @@ -266,7 +327,7 @@ where } crate::declare_variants! { - default RuralPostman => "2^num_vertices * num_vertices^2", + default RuralPostman => "2^num_vertices * num_vertices^2" create RuralPostmanCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index bfc1272b8..4494edf6e 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -4,7 +4,7 @@ //! source vertex to a target vertex that minimizes total length while keeping //! the total weight within a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -23,14 +23,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find a simple s-t path minimizing total length subject to a weight budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound W on total path weight" }, - ], + fields: ShortestWeightConstrainedPathCreateSpec::FIELDS, } } @@ -74,6 +67,73 @@ pub struct ShortestWeightConstrainedPath { weight_bound: N::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestWeightConstrainedPathCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Positive edge lengths in graph edge order. + edge_lengths: Vec, + /// Positive edge weights in graph edge order. + edge_weights: Vec, + /// Source vertex s. + source_vertex: usize, + /// Target vertex t. + target_vertex: usize, + /// Positive upper bound on total path weight. + weight_bound: i64, +} + +impl TryFrom + for ShortestWeightConstrainedPath +{ + type Error = String; + fn try_from(spec: ShortestWeightConstrainedPathCreateSpec) -> Result { + let edge_count = spec.graph.num_edges(); + if spec.edge_lengths.len() != edge_count { + return Err(format!( + "edge_lengths has {} entries, expected {edge_count}", + spec.edge_lengths.len() + )); + } + if spec.edge_weights.len() != edge_count { + return Err(format!( + "edge_weights has {} entries, expected {edge_count}", + spec.edge_weights.len() + )); + } + if spec.edge_lengths.iter().any(|&value| value <= 0) { + return Err("edge_lengths must be positive".to_string()); + } + if spec.edge_weights.iter().any(|&value| value <= 0) { + return Err("edge_weights must be positive".to_string()); + } + let vertex_count = spec.graph.num_vertices(); + if spec.source_vertex >= vertex_count { + return Err(format!( + "source_vertex {} is outside graph with {vertex_count} vertices", + spec.source_vertex + )); + } + if spec.target_vertex >= vertex_count { + return Err(format!( + "target_vertex {} is outside graph with {vertex_count} vertices", + spec.target_vertex + )); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + Ok(Self::new( + spec.graph, + spec.edge_lengths, + spec.edge_weights, + spec.source_vertex, + spec.target_vertex, + spec.weight_bound, + )) + } +} + impl ShortestWeightConstrainedPath { fn assert_positive_edge_values(values: &[N], label: &str) { let zero = N::Sum::zero(); @@ -349,7 +409,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_edges", + default ShortestWeightConstrainedPath => "2^num_edges" create ShortestWeightConstrainedPathCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index 0e86144a5..a3f51e55b 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -2,7 +2,7 @@ //! //! The Spin Glass problem minimizes the Ising Hamiltonian energy. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -19,11 +19,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Minimize Ising Hamiltonian on a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The interaction graph" }, - FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings J_ij" }, - FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields h_i" }, - ], + fields: SpinGlassI32CreateSpec::FIELDS, } } @@ -73,6 +69,72 @@ pub struct SpinGlass { fields: Vec, } +macro_rules! spin_glass_create_spec { + ($name:ident, $weight:ty, $one:expr, $zero:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Undirected interaction graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated spins. + num_vertices: Option, + /// Pairwise couplings; defaults to one per edge. + #[create(codec = "comma-separated")] + couplings: Option>, + /// On-site fields; defaults to zero per vertex. + #[create(codec = "comma-separated")] + fields: Option>, + } + + impl TryFrom<$name> for SpinGlass { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + let couplings = spec + .couplings + .unwrap_or_else(|| vec![$one; spec.graph.len()]); + if couplings.len() != spec.graph.len() { + return Err("couplings length must match graph edge count".to_string()); + } + let fields = spec.fields.unwrap_or_else(|| vec![$zero; num_vertices]); + if fields.len() != num_vertices { + return Err("fields length must match num_vertices".to_string()); + } + Ok(SpinGlass { + graph: SimpleGraph::new(num_vertices, spec.graph), + couplings, + fields, + }) + } + } + }; +} + +spin_glass_create_spec!(SpinGlassI32CreateSpec, i32, 1_i32, 0_i32); +spin_glass_create_spec!(SpinGlassF64CreateSpec, f64, 1.0_f64, 0.0_f64); + impl SpinGlass { /// Create a new Spin Glass problem. /// @@ -237,8 +299,8 @@ where } crate::declare_variants! { - default SpinGlass => "2^num_spins", - SpinGlass => "2^num_spins", + default SpinGlass => "2^num_spins" create SpinGlassI32CreateSpec, + SpinGlass => "2^num_spins" create SpinGlassF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index b58f8c331..83436d678 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -9,7 +9,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; use crate::{ - registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}, + registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}, topology::{Graph, SimpleGraph}, traits::Problem, types::{Min, One, WeightElement}, @@ -26,11 +26,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight tree connecting terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices T that must be connected" }, - ], + fields: SteinerTreeCreateSpec::::FIELDS, } } @@ -64,6 +60,49 @@ pub struct SteinerTree { terminals: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Edge weights w: E -> R. + edge_weights: Vec, + /// Terminal vertices T that must be connected. + terminals: Vec, +} + +impl TryFrom> for SteinerTree { + type Error = String; + fn try_from(spec: SteinerTreeCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.edge_weights, spec.terminals)) + } +} + impl SteinerTree { /// Create a SteinerTree problem from a graph, edge weights, and terminals. pub fn new(graph: G, edge_weights: Vec, terminals: Vec) -> Self { @@ -248,8 +287,8 @@ where } crate::declare_variants! { - default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", - SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", + default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec, + SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs index 9a191078c..4fbc64a2c 100644 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -3,7 +3,7 @@ //! The Steiner Tree problem asks for a minimum-weight subtree of a graph //! that connects all terminal vertices. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -21,11 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight subtree connecting all terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Required terminal vertices R ⊆ V" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: SteinerTreeInGraphsCreateSpec::::FIELDS, } } @@ -77,6 +73,42 @@ pub struct SteinerTreeInGraphs { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeInGraphsCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Required terminal vertices. + terminals: Vec, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, +} +impl TryFrom> for SteinerTreeInGraphs +where + W: Clone + Default + From, +{ + type Error = String; + fn try_from(spec: SteinerTreeInGraphsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!("terminal {terminal} is outside the graph")); + } + Ok(Self::new(spec.graph, spec.terminals, edge_weights)) + } +} + impl SteinerTreeInGraphs { /// Create a SteinerTreeInGraphs problem from a graph, terminals, and edge weights. /// @@ -274,8 +306,8 @@ pub(crate) fn is_steiner_tree(graph: &G, terminals: &[usize], selected } crate::declare_variants! { - default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", - SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", + default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, + SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index 017fabf7c..fdd71417e 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Traveling Salesman problem asks for a minimum-weight cycle //! that visits every vertex exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -21,10 +21,7 @@ inventory::submit! { ], module_path: module_path!(), description: "Find minimum weight Hamiltonian cycle in a graph (Traveling Salesman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: TravelingSalesmanCreateSpec::FIELDS, } } @@ -57,6 +54,62 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for TravelingSalesman { + type Error = String; + + fn try_from(spec: TravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl TravelingSalesman { /// Create a TravelingSalesman problem from a graph with given edge weights. pub fn new(graph: G, edge_weights: Vec) -> Self { @@ -260,7 +313,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_vertices", + default TravelingSalesman => "2^num_vertices" create TravelingSalesmanCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index be86186dd..38822478d 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -13,7 +13,7 @@ //! lower bounds, so the registered exact complexity matches brute-force //! enumeration over the `2^|E|` edge orientations. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -27,14 +27,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether an undirected lower-bounded flow of value at least R exists", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Upper capacities c(e) in graph edge order" }, - FieldInfo { name: "lower_bounds", type_name: "Vec", description: "Lower bounds l(e) in graph edge order" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at sink t" }, - ], + fields: UndirectedFlowLowerBoundsCreateSpec::FIELDS, } } @@ -55,6 +48,67 @@ pub struct UndirectedFlowLowerBounds { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedFlowLowerBoundsCreateSpec { + /// Undirected graph. + graph: SimpleGraph, + /// Upper capacities in graph edge order. + capacities: Vec, + /// Lower bounds in graph edge order. + lower_bounds: Vec, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Required net inflow at the sink. + requirement: u64, +} +impl TryFrom for UndirectedFlowLowerBounds { + type Error = String; + fn try_from(spec: UndirectedFlowLowerBoundsCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + if spec.capacities.len() != edges { + return Err(format!( + "capacities has {} entries, expected {edges}", + spec.capacities.len() + )); + } + if spec.lower_bounds.len() != edges { + return Err(format!( + "lower_bounds has {} entries, expected {edges}", + spec.lower_bounds.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.requirement == 0 { + return Err("requirement must be at least 1".to_string()); + } + if let Some((index, _)) = spec + .lower_bounds + .iter() + .zip(&spec.capacities) + .enumerate() + .find(|(_, (&lower, &upper))| lower > upper) + { + return Err(format!("lower bound at edge {index} exceeds its capacity")); + } + Ok(Self::new( + spec.graph, + spec.capacities, + spec.lower_bounds, + spec.source, + spec.sink, + spec.requirement, + )) + } +} + impl UndirectedFlowLowerBounds { pub fn new( graph: SimpleGraph, @@ -232,7 +286,7 @@ impl Problem for UndirectedFlowLowerBounds { } crate::declare_variants! { - default UndirectedFlowLowerBounds => "2^num_edges", + default UndirectedFlowLowerBounds => "2^num_edges" create UndirectedFlowLowerBoundsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index 6159336b8..9d8666821 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -3,7 +3,7 @@ //! The problem asks whether two integral commodities can be routed through an //! undirected capacitated graph while sharing edge capacities. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,16 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether two integral commodities can satisfy sink demands in an undirected capacitated graph", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Edge capacities c(e) in graph edge order" }, - FieldInfo { name: "source_1", type_name: "usize", description: "Source vertex s_1 for commodity 1" }, - FieldInfo { name: "sink_1", type_name: "usize", description: "Sink vertex t_1 for commodity 1" }, - FieldInfo { name: "source_2", type_name: "usize", description: "Source vertex s_2 for commodity 2" }, - FieldInfo { name: "sink_2", type_name: "usize", description: "Sink vertex t_2 for commodity 2" }, - FieldInfo { name: "requirement_1", type_name: "u64", description: "Required net inflow R_1 at sink t_1" }, - FieldInfo { name: "requirement_2", type_name: "u64", description: "Required net inflow R_2 at sink t_2" }, - ], + fields: UndirectedTwoCommodityIntegralFlowCreateSpec::FIELDS, } } @@ -56,6 +47,82 @@ pub struct UndirectedTwoCommodityIntegralFlow { requirement_2: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedTwoCommodityIntegralFlowCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Edge capacities. + #[create(codec = "comma-separated")] + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: u64, + requirement_2: u64, +} + +impl TryFrom for UndirectedTwoCommodityIntegralFlow { + type Error = String; + fn try_from(spec: UndirectedTwoCommodityIntegralFlowCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.capacities.len() != spec.graph.len() { + return Err("capacities length must match graph edge count".into()); + } + for &capacity in &spec.capacities { + if usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large for this platform".into()); + } + } + for (label, vertex) in [ + ("source_1", spec.source_1), + ("sink_1", spec.sink_1), + ("source_2", spec.source_2), + ("sink_2", spec.sink_2), + ] { + if vertex >= count { + return Err(format!("{label} must be less than num_vertices")); + } + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + capacities: spec.capacities, + source_1: spec.source_1, + sink_1: spec.sink_1, + source_2: spec.source_2, + sink_2: spec.sink_2, + requirement_1: spec.requirement_1, + requirement_2: spec.requirement_2, + }) + } +} + impl UndirectedTwoCommodityIntegralFlow { #[allow(clippy::too_many_arguments)] pub fn new( @@ -299,7 +366,7 @@ impl Problem for UndirectedTwoCommodityIntegralFlow { } crate::declare_variants! { - default UndirectedTwoCommodityIntegralFlow => "5^num_edges", + default UndirectedTwoCommodityIntegralFlow => "5^num_edges" create UndirectedTwoCommodityIntegralFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/boyce_codd_normal_form_violation.rs b/src/models/misc/boyce_codd_normal_form_violation.rs index 3e465c5bf..7d6cc1332 100644 --- a/src/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/models/misc/boyce_codd_normal_form_violation.rs @@ -5,7 +5,7 @@ //! `X ⊆ A'` such that the closure of `X` under the functional dependencies contains //! some but not all attributes of `A' \ X` — i.e., a witness to a BCNF violation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Test whether a subset of attributes violates Boyce-Codd normal form", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Total number of attributes in A" }, - FieldInfo { name: "functional_deps", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs_attributes, rhs_attributes)" }, - FieldInfo { name: "target_subset", type_name: "Vec", description: "Subset A' of attributes to test for BCNF violation" }, - ], + fields: BoyceCoddNormalFormViolationCreateSpec::FIELDS, } } @@ -68,6 +64,51 @@ pub struct BoyceCoddNormalFormViolation { target_subset: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoyceCoddNormalFormViolationCreateSpec { + /// Total number of attributes in A. + n: usize, + /// Functional dependencies (lhs attributes, rhs attributes). + #[create(codec = "functional-dependency-list")] + subsets: Vec<(Vec, Vec)>, + /// Subset A' of attributes to test for BCNF violation. + target: Vec, +} + +impl TryFrom for BoyceCoddNormalFormViolation { + type Error = String; + + fn try_from(spec: BoyceCoddNormalFormViolationCreateSpec) -> Result { + if spec.target.is_empty() { + return Err("target must be non-empty".to_string()); + } + for (dependency_index, (lhs, rhs)) in spec.subsets.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "subsets[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.n) + { + return Err(format!( + "subsets[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + } + if let Some(&attribute) = spec.target.iter().find(|&&attribute| attribute >= spec.n) { + return Err(format!( + "target contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + Ok(Self::new(spec.n, spec.subsets, spec.target)) + } +} + impl BoyceCoddNormalFormViolation { /// Create a new Boyce-Codd Normal Form Violation instance. /// @@ -216,7 +257,7 @@ impl Problem for BoyceCoddNormalFormViolation { } crate::declare_variants! { - default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps", + default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps" create BoyceCoddNormalFormViolationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index cd4426daf..ecb7e27e5 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -3,7 +3,7 @@ //! Capacity Assignment asks for the minimum-cost assignment of capacity levels //! to communication links, subject to a delay budget constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,12 +15,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize total cost of capacity assignment subject to a delay budget", - fields: &[ - FieldInfo { name: "capacities", type_name: "Vec", description: "Ordered capacity levels M" }, - FieldInfo { name: "cost", type_name: "Vec>", description: "Cost matrix g(c, m) for each link and capacity" }, - FieldInfo { name: "delay", type_name: "Vec>", description: "Delay matrix d(c, m) for each link and capacity" }, - FieldInfo { name: "delay_budget", type_name: "u64", description: "Budget J on total delay penalty" }, - ], + fields: CapacityAssignmentCreateSpec::FIELDS, } } @@ -38,6 +33,57 @@ pub struct CapacityAssignment { delay_budget: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct CapacityAssignmentCreateSpec { + #[create(codec = "comma-separated")] + capacities: Vec, + #[create(codec = "semicolon-separated")] + cost: Vec>, + #[create(codec = "semicolon-separated")] + delay: Vec>, + delay_budget: u64, +} + +impl TryFrom for CapacityAssignment { + type Error = String; + fn try_from(spec: CapacityAssignmentCreateSpec) -> Result { + if spec.capacities.is_empty() { + return Err("capacities must be non-empty".into()); + } + if spec.capacities.contains(&0) { + return Err("capacities must be positive".into()); + } + if !spec.capacities.windows(2).all(|w| w[0] < w[1]) { + return Err("capacities must be strictly increasing".into()); + } + if spec.cost.len() != spec.delay.len() { + return Err("cost and delay must have the same number of links".into()); + } + for (i, row) in spec.cost.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("cost row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] <= w[1]) { + return Err(format!("cost row {i} must be non-decreasing")); + } + } + for (i, row) in spec.delay.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("delay row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] >= w[1]) { + return Err(format!("delay row {i} must be non-increasing")); + } + } + Ok(Self { + capacities: spec.capacities, + cost: spec.cost, + delay: spec.delay, + delay_budget: spec.delay_budget, + }) + } +} + impl CapacityAssignment { /// Create a new Capacity Assignment instance. pub fn new( @@ -169,7 +215,7 @@ impl Problem for CapacityAssignment { } crate::declare_variants! { - default CapacityAssignment => "num_capacities ^ num_links", + default CapacityAssignment => "num_capacities ^ num_links" create CapacityAssignmentCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/conjunctive_boolean_query.rs b/src/models/misc/conjunctive_boolean_query.rs index e9e4b8737..fad6fdd9e 100644 --- a/src/models/misc/conjunctive_boolean_query.rs +++ b/src/models/misc/conjunctive_boolean_query.rs @@ -10,7 +10,7 @@ //! the domain. The query is satisfiable iff there exists an assignment to the //! variables such that every conjunct's resolved tuple belongs to its relation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -22,12 +22,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Evaluate a conjunctive Boolean query over a relational database", - fields: &[ - FieldInfo { name: "domain_size", type_name: "usize", description: "Size of the finite domain D" }, - FieldInfo { name: "relations", type_name: "Vec", description: "Collection of relations R" }, - FieldInfo { name: "num_variables", type_name: "usize", description: "Number of existentially quantified variables" }, - FieldInfo { name: "conjuncts", type_name: "Vec<(usize, Vec)>", description: "Query conjuncts: (relation_index, arguments)" }, - ], + fields: ConjunctiveBooleanQueryCreateSpec::FIELDS, } } @@ -87,6 +82,89 @@ pub struct ConjunctiveBooleanQuery { conjuncts: Vec<(usize, Vec)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConjunctiveBooleanQueryCreateSpec { + /// Size of the finite domain. + domain_size: usize, + /// Relations evaluated by the query. + #[create(codec = "json")] + relations: Vec, + /// Query atoms; the number of variables is inferred from their arguments. + #[create(codec = "json")] + conjuncts: Vec<(usize, Vec)>, +} + +impl TryFrom for ConjunctiveBooleanQuery { + type Error = String; + + fn try_from(spec: ConjunctiveBooleanQueryCreateSpec) -> Result { + let mut num_variables = 0_usize; + for (_, args) in &spec.conjuncts { + for arg in args { + if let QueryArg::Variable(variable) = arg { + let count = variable + .checked_add(1) + .ok_or_else(|| "number of query variables overflows usize".to_string())?; + num_variables = num_variables.max(count); + } + } + } + + for (relation_index, relation) in spec.relations.iter().enumerate() { + for (tuple_index, tuple) in relation.tuples.iter().enumerate() { + if tuple.len() != relation.arity { + return Err(format!( + "relation {relation_index} tuple {tuple_index} has length {}, expected arity {}", + tuple.len(), + relation.arity + )); + } + for (entry_index, &value) in tuple.iter().enumerate() { + if value >= spec.domain_size { + return Err(format!( + "relation {relation_index} tuple {tuple_index} entry {entry_index} is {value}, must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + for (conjunct_index, (relation_index, args)) in spec.conjuncts.iter().enumerate() { + let relation = spec.relations.get(*relation_index).ok_or_else(|| { + format!( + "conjunct {conjunct_index} relation index {relation_index} is out of range for {} relations", + spec.relations.len() + ) + })?; + if args.len() != relation.arity { + return Err(format!( + "conjunct {conjunct_index} has {} arguments, expected arity {}", + args.len(), + relation.arity + )); + } + for (argument_index, arg) in args.iter().enumerate() { + if let QueryArg::Constant(value) = arg { + if *value >= spec.domain_size { + return Err(format!( + "conjunct {conjunct_index} argument {argument_index} constant {value} must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + Ok(Self { + domain_size: spec.domain_size, + relations: spec.relations, + num_variables, + conjuncts: spec.conjuncts, + }) + } +} + impl ConjunctiveBooleanQuery { /// Create a new ConjunctiveBooleanQuery instance. /// @@ -224,7 +302,7 @@ impl Problem for ConjunctiveBooleanQuery { } crate::declare_variants! { - default ConjunctiveBooleanQuery => "domain_size ^ num_variables", + default ConjunctiveBooleanQuery => "domain_size ^ num_variables" create ConjunctiveBooleanQueryCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 04ade7a9e..250751da7 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -6,7 +6,7 @@ //! assignment of attribute values to all objects that matches every published //! frequency table and every known value. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -90,12 +90,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether pairwise frequency tables and known values admit a consistent complete database assignment", - fields: &[ - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects in the database" }, - FieldInfo { name: "attribute_domains", type_name: "Vec", description: "Domain size for each attribute" }, - FieldInfo { name: "frequency_tables", type_name: "Vec", description: "Published pairwise frequency tables" }, - FieldInfo { name: "known_values", type_name: "Vec", description: "Known object-attribute-value triples" }, - ], + fields: ConsistencyOfDatabaseFrequencyTablesCreateSpec::FIELDS, } } @@ -108,6 +103,110 @@ pub struct ConsistencyOfDatabaseFrequencyTables { known_values: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConsistencyOfDatabaseFrequencyTablesCreateSpec { + /// Number of database objects. + num_objects: usize, + /// Domain size for each attribute. + #[create(codec = "comma-separated")] + attribute_domains: Vec, + /// Pairwise frequency tables as JSON objects. + #[create(codec = "json")] + frequency_tables: Vec, + /// Known object-attribute values as JSON objects; defaults to empty. + #[create(codec = "json")] + known_values: Option>, +} + +impl TryFrom + for ConsistencyOfDatabaseFrequencyTables +{ + type Error = String; + fn try_from(spec: ConsistencyOfDatabaseFrequencyTablesCreateSpec) -> Result { + let known_values = spec.known_values.unwrap_or_default(); + validate_cdft_create( + spec.num_objects, + &spec.attribute_domains, + &spec.frequency_tables, + &known_values, + )?; + Ok(Self { + num_objects: spec.num_objects, + attribute_domains: spec.attribute_domains, + frequency_tables: spec.frequency_tables, + known_values, + }) + } +} + +fn validate_cdft_create( + num_objects: usize, + domains: &[usize], + tables: &[FrequencyTable], + known: &[KnownValue], +) -> Result<(), String> { + for (attribute, &size) in domains.iter().enumerate() { + if size == 0 { + return Err(format!( + "attribute domain size at index {attribute} must be positive" + )); + } + } + let mut pairs = BTreeSet::new(); + for table in tables { + let a = table.attribute_a(); + let b = table.attribute_b(); + if a >= domains.len() || b >= domains.len() { + return Err("frequency table attribute is out of range".into()); + } + if a == b { + return Err("frequency table attributes must be distinct".into()); + } + let pair = if a < b { (a, b) } else { (b, a) }; + if !pairs.insert(pair) { + return Err(format!( + "duplicate frequency table pair ({}, {})", + pair.0, pair.1 + )); + } + if table.counts().len() != domains[a] { + return Err(format!( + "frequency table row count must equal domain size for attribute {a}" + )); + } + if table.counts().iter().any(|row| row.len() != domains[b]) { + return Err(format!( + "frequency table column count must equal domain size for attribute {b}" + )); + } + let total = table + .counts() + .iter() + .flatten() + .try_fold(0usize, |sum, &value| { + sum.checked_add(value) + .ok_or("frequency table count total overflows usize") + })?; + if total != num_objects { + return Err(format!( + "frequency table total {total} must equal num_objects {num_objects}" + )); + } + } + for value in known { + if value.object() >= num_objects { + return Err("known value object is out of range".into()); + } + if value.attribute() >= domains.len() { + return Err("known value attribute is out of range".into()); + } + if value.value() >= domains[value.attribute()] { + return Err("known value is outside the attribute domain".into()); + } + } + Ok(()) +} + impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. pub fn new( @@ -336,7 +435,7 @@ impl Problem for ConsistencyOfDatabaseFrequencyTables { } crate::declare_variants! { - default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects", + default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/grouping_by_swapping.rs b/src/models/misc/grouping_by_swapping.rs index ff6cc0d36..e9f99cb5b 100644 --- a/src/models/misc/grouping_by_swapping.rs +++ b/src/models/misc/grouping_by_swapping.rs @@ -4,7 +4,7 @@ //! whether at most `K` adjacent swaps can transform the string so that every //! symbol appears in a single contiguous block. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Group equal symbols into contiguous blocks using at most K adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "string", type_name: "Vec", description: "Input string over {0, ..., alphabet_size-1}" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number of adjacent swaps allowed" }, - ], + fields: GroupingBySwappingCreateSpec::FIELDS, } } @@ -36,6 +32,54 @@ pub struct GroupingBySwapping { budget: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GroupingBySwappingCreateSpec { + /// Optional alphabet size; omitted values are inferred from the string. + alphabet_size: Option, + /// Input string to group. + #[create(codec = "comma-separated")] + string: Vec, + /// Maximum number of adjacent swaps. + bound: usize, +} + +impl TryFrom for GroupingBySwapping { + type Error = String; + + fn try_from(spec: GroupingBySwappingCreateSpec) -> Result { + let inferred_alphabet_size = spec + .string + .iter() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && !spec.string.is_empty() { + return Err("alphabet size must be positive for a non-empty string".to_string()); + } + if spec.string.is_empty() && spec.bound != 0 { + return Err("bound must be zero when the string is empty".to_string()); + } + + Ok(Self { + alphabet_size, + string: spec.string, + budget: spec.bound, + }) + } +} + impl GroupingBySwapping { /// Create a new GroupingBySwapping instance. /// @@ -160,7 +204,7 @@ impl Problem for GroupingBySwapping { } crate::declare_variants! { - default GroupingBySwapping => "string_len ^ budget", + default GroupingBySwapping => "string_len ^ budget" create GroupingBySwappingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs index be2dbb4bf..bdec8b68d 100644 --- a/src/models/misc/job_shop_scheduling.rs +++ b/src/models/misc/job_shop_scheduling.rs @@ -5,7 +5,7 @@ //! makespan (completion time of the last task) while respecting both within-job //! precedence and single-processor capacity constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,10 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize the makespan of a job-shop schedule", - fields: &[ - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "jobs", type_name: "Vec>", description: "jobs[j][k] = (processor, length) for the k-th task of job j" }, - ], + fields: JobShopSchedulingCreateSpec::FIELDS, } } @@ -32,6 +29,64 @@ pub struct JobShopScheduling { jobs: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct JobShopSchedulingCreateSpec { + /// Jobs expressed as ordered processor-duration operations. + #[create(codec = "semicolon-separated")] + jobs: Vec>, + /// Optional processor count; omitted values are inferred from the jobs. + num_processors: Option, +} + +impl TryFrom for JobShopScheduling { + type Error = String; + + fn try_from(spec: JobShopSchedulingCreateSpec) -> Result { + let inferred_processors = spec + .jobs + .iter() + .flatten() + .map(|(processor, _)| *processor) + .max() + .map(|processor| { + processor + .checked_add(1) + .ok_or_else(|| "inferred processor count overflows usize".to_string()) + }) + .transpose()?; + let num_processors = spec.num_processors.or(inferred_processors).ok_or_else(|| { + "cannot infer processor count from an empty job list; provide num_processors" + .to_string() + })?; + if num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + + for (job_index, job) in spec.jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + if processor >= num_processors { + return Err(format!( + "job {job_index} task {task_index} uses processor {processor}, but num_processors is {num_processors}" + )); + } + } + for (task_index, pair) in job.windows(2).enumerate() { + if pair[0].0 == pair[1].0 { + return Err(format!( + "job {job_index} tasks {task_index} and {} must use different processors", + task_index + 1 + )); + } + } + } + + Ok(Self { + num_processors, + jobs: spec.jobs, + }) + } +} + struct FlattenedTasks { job_task_ids: Vec>, machine_task_ids: Vec>, @@ -234,7 +289,7 @@ impl Problem for JobShopScheduling { } crate::declare_variants! { - default JobShopScheduling => "factorial(num_tasks)", + default JobShopScheduling => "factorial(num_tasks)" create JobShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/knapsack.rs b/src/models/misc/knapsack.rs index dc98f7198..1b268c486 100644 --- a/src/models/misc/knapsack.rs +++ b/src/models/misc/knapsack.rs @@ -3,7 +3,7 @@ //! The 0-1 Knapsack problem asks for a subset of items that maximizes //! total value while respecting a weight capacity constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Select items to maximize total value subject to weight capacity constraint", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Nonnegative item weights w_i" }, - FieldInfo { name: "values", type_name: "Vec", description: "Nonnegative item values v_i" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Nonnegative knapsack capacity C" }, - ], + fields: KnapsackCreateSpec::FIELDS, } } @@ -63,6 +59,33 @@ pub struct Knapsack { capacity: i64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KnapsackCreateSpec { + /// Nonnegative item weights; defaults to one per value. + weights: Option>, + /// Nonnegative item values. + values: Vec, + /// Nonnegative knapsack capacity. + capacity: i64, +} +impl TryFrom for Knapsack { + type Error = String; + fn try_from(spec: KnapsackCreateSpec) -> Result { + let count = spec.values.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal values length".to_string()); + } + if weights.iter().any(|&value| value < 0) + || spec.values.iter().any(|&value| value < 0) + || spec.capacity < 0 + { + return Err("weights, values, and capacity must be nonnegative".to_string()); + } + Ok(Self::new(weights, spec.values, spec.capacity)) + } +} + impl Knapsack { /// Create a new Knapsack instance. /// @@ -163,7 +186,7 @@ impl Problem for Knapsack { } crate::declare_variants! { - default Knapsack => "2^(num_items / 2)", + default Knapsack => "2^(num_items / 2)" create KnapsackCreateSpec, } mod nonnegative_i64 { diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 79b8078b1..49489f939 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -4,7 +4,7 @@ //! at least K distinct m-tuples (one element per set) have total size at least B. //! Garey & Johnson MP10. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Or; use serde::de::Error as _; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Count m-tuples whose total size meets a bound and compare against a threshold K", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "m sets, each containing positive integer sizes" }, - FieldInfo { name: "k", type_name: "u64", description: "Threshold K (answer YES iff count >= K)" }, - FieldInfo { name: "bound", type_name: "u64", description: "Lower bound B on tuple sum" }, - ], + fields: KthLargestMTupleCreateSpec::FIELDS, } } @@ -68,6 +64,24 @@ pub struct KthLargestMTuple { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthLargestMTupleCreateSpec { + /// m sets, each containing positive integer sizes. + subsets: Vec>, + /// Threshold K (answer YES iff count >= K). + k: u64, + /// Lower bound B on tuple sum. + bound: u64, +} + +impl TryFrom for KthLargestMTuple { + type Error = String; + + fn try_from(spec: KthLargestMTupleCreateSpec) -> Result { + Self::try_new(spec.subsets, spec.k, spec.bound) + } +} + impl KthLargestMTuple { fn validate(sets: &[Vec], k: u64, bound: u64) -> Result<(), String> { if sets.is_empty() { @@ -201,7 +215,7 @@ impl Problem for KthLargestMTuple { // Best known: brute-force enumeration of all tuples, O(total_tuples * num_sets). // No sub-exponential exact algorithm is known for the general case. crate::declare_variants! { - default KthLargestMTuple => "total_tuples * num_sets", + default KthLargestMTuple => "total_tuples * num_sets" create KthLargestMTupleCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 7425ad942..20b05e6a3 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -5,7 +5,7 @@ //! `max_length` positions, where each entry is either a valid symbol or the //! padding symbol (`alphabet_size`). Padding must be contiguous at the end. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a longest common subsequence for a set of strings", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "strings", type_name: "Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible subsequence length (min of string lengths)" }, - ], + fields: LongestCommonSubsequenceCreateSpec::FIELDS, } } @@ -45,6 +41,54 @@ pub struct LongestCommonSubsequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCommonSubsequenceCreateSpec { + /// Optional alphabet size; omitted values are inferred from the strings. + alphabet_size: Option, + /// Input strings over the shared alphabet. + #[create(codec = "character-rows")] + strings: Vec>, +} + +impl TryFrom for LongestCommonSubsequence { + type Error = String; + + fn try_from(spec: LongestCommonSubsequenceCreateSpec) -> Result { + if !spec.strings.iter().any(|string| !string.is_empty()) { + return Err("at least one input string must be non-empty".to_string()); + } + let inferred_alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 { + return Err("alphabet size must be positive".to_string()); + } + let max_length = spec.strings.iter().map(Vec::len).min().unwrap_or(0); + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl LongestCommonSubsequence { /// Create a new LongestCommonSubsequence instance. /// @@ -203,7 +247,7 @@ impl Problem for LongestCommonSubsequence { } crate::declare_variants! { - default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length", + default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length" create LongestCommonSubsequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index 453b80089..8fdbe13d3 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -4,7 +4,7 @@ //! that identifies each object with minimum total external path length //! (sum of depths of all leaves). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,11 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find decision tree identifying objects with minimum total path length", - fields: &[ - FieldInfo { name: "test_matrix", type_name: "Vec>", description: "Binary matrix: test_matrix[j][i] = object i passes test j" }, - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects to identify" }, - FieldInfo { name: "num_tests", type_name: "usize", description: "Number of available binary tests" }, - ], + fields: MinimumDecisionTreeCreateSpec::FIELDS, } } @@ -62,6 +58,55 @@ pub struct MinimumDecisionTree { num_tests: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDecisionTreeCreateSpec { + /// Binary test matrix as JSON. + #[create(codec = "json")] + test_matrix: Vec>, + /// Number of objects. + num_objects: usize, + /// Number of tests. + num_tests: usize, +} + +impl TryFrom for MinimumDecisionTree { + type Error = String; + fn try_from(spec: MinimumDecisionTreeCreateSpec) -> Result { + if spec.num_objects < 2 { + return Err("num_objects must be at least 2".into()); + } + if spec.num_tests == 0 { + return Err("num_tests must be positive".into()); + } + if spec.test_matrix.len() != spec.num_tests { + return Err("test_matrix row count must equal num_tests".into()); + } + if spec + .test_matrix + .iter() + .any(|row| row.len() != spec.num_objects) + { + return Err("each test_matrix row must have num_objects columns".into()); + } + for a in 0..spec.num_objects { + for b in a + 1..spec.num_objects { + if !(0..spec.num_tests) + .any(|test| spec.test_matrix[test][a] != spec.test_matrix[test][b]) + { + return Err(format!( + "objects {a} and {b} are not distinguished by any test" + )); + } + } + } + Ok(Self { + test_matrix: spec.test_matrix, + num_objects: spec.num_objects, + num_tests: spec.num_tests, + }) + } +} + impl MinimumDecisionTree { /// Create a new MinimumDecisionTree problem. /// @@ -187,7 +232,7 @@ impl Problem for MinimumDecisionTree { } crate::declare_variants! { - default MinimumDecisionTree => "num_tests^num_objects", + default MinimumDecisionTree => "num_tests^num_objects" create MinimumDecisionTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index f507094d5..a743fcc4c 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -8,7 +8,7 @@ //! - `MinimumTardinessSequencing` — unit-length tasks (`1|prec, pj=1|∑Uj`) //! - `MinimumTardinessSequencing` — arbitrary-length tasks (`1|prec|∑Uj`) -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; use serde::{Deserialize, Serialize}; @@ -21,11 +21,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "One", &["One", "i32"])], module_path: module_path!(), description: "Schedule tasks with precedence constraints and deadlines to minimize the number of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: MinimumTardinessSequencingOneCreateSpec::FIELDS, } } @@ -64,6 +60,64 @@ pub struct MinimumTardinessSequencing { precedences: Vec<(usize, usize)>, } +macro_rules! minimum_tardiness_create_spec { + ($name:ident, $weight:ty, $construct:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + lengths: Vec<$weight>, + deadlines: Vec, + precedences: Option>, + } + + impl TryFrom<$name> for MinimumTardinessSequencing<$weight> { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.lengths.len() != spec.deadlines.len() { + return Err("lengths and deadlines must have the same length".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_tasks = spec.lengths.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_tasks || succ >= num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_tasks} tasks" + )); + } + $construct(spec.lengths, spec.deadlines, precedences) + } + } + }; +} + +minimum_tardiness_create_spec!( + MinimumTardinessSequencingOneCreateSpec, + One, + |lengths: Vec, deadlines, precedences| { + Ok(MinimumTardinessSequencing::new( + lengths.len(), + deadlines, + precedences, + )) + } +); +minimum_tardiness_create_spec!( + MinimumTardinessSequencingI32CreateSpec, + i32, + |lengths: Vec, deadlines, precedences| { + if lengths.iter().any(|&length| length <= 0) { + return Err("all task lengths must be positive".to_string()); + } + Ok(MinimumTardinessSequencing::with_lengths( + lengths, + deadlines, + precedences, + )) + } +); + impl MinimumTardinessSequencing { /// Create a new unit-length MinimumTardinessSequencing instance. /// @@ -247,8 +301,8 @@ impl Problem for MinimumTardinessSequencing { } crate::declare_variants! { - default MinimumTardinessSequencing => "2^num_tasks", - MinimumTardinessSequencing => "2^num_tasks", + default MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingOneCreateSpec, + MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingI32CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_weight_and_or_graph.rs b/src/models/misc/minimum_weight_and_or_graph.rs index dc497a6f5..662fc3595 100644 --- a/src/models/misc/minimum_weight_and_or_graph.rs +++ b/src/models/misc/minimum_weight_and_or_graph.rs @@ -3,7 +3,7 @@ //! Given a directed acyclic graph with AND/OR gates, find the minimum-weight //! solution subgraph from a designated source vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Deserializer, Serialize}; @@ -16,13 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find the minimum-weight solution subgraph from a source in a DAG with AND/OR gates", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the DAG" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Directed arcs (u, v)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex index" }, - FieldInfo { name: "gate_types", type_name: "Vec>", description: "Gate type per vertex: Some(true)=AND, Some(false)=OR, None=leaf" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Weight of each arc" }, - ], + fields: MinimumWeightAndOrGraphCreateSpec::FIELDS, } } @@ -78,6 +72,56 @@ pub struct MinimumWeightAndOrGraph { outgoing: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumWeightAndOrGraphCreateSpec { + /// Number of vertices in the DAG. + num_vertices: usize, + /// Directed arcs. + arcs: Vec<(usize, usize)>, + /// Source vertex. + source: usize, + /// Gate type per vertex. + gate_types: Vec>, + /// Arc weights; defaults to one per arc. + arc_weights: Option>, +} +impl TryFrom for MinimumWeightAndOrGraph { + type Error = String; + fn try_from(spec: MinimumWeightAndOrGraphCreateSpec) -> Result { + if spec.source >= spec.num_vertices { + return Err("source is outside the graph".to_string()); + } + if spec.gate_types.len() != spec.num_vertices { + return Err("gate_types length must equal num_vertices".to_string()); + } + if spec.gate_types[spec.source].is_none() { + return Err("source must be an AND or OR gate".to_string()); + } + if let Some(&(u, v)) = spec + .arcs + .iter() + .find(|&&(u, v)| u >= spec.num_vertices || v >= spec.num_vertices) + { + return Err(format!("arc ({u}, {v}) is out of bounds")); + } + let count = spec.arcs.len(); + let arc_weights = spec.arc_weights.unwrap_or_else(|| vec![1; count]); + if arc_weights.len() != count { + return Err(format!( + "arc_weights has {} entries, expected {count}", + arc_weights.len() + )); + } + Ok(Self::new( + spec.num_vertices, + spec.arcs, + spec.source, + spec.gate_types, + arc_weights, + )) + } +} + #[derive(Deserialize)] struct MinimumWeightAndOrGraphData { num_vertices: usize, @@ -295,7 +339,7 @@ impl Problem for MinimumWeightAndOrGraph { } crate::declare_variants! { - default MinimumWeightAndOrGraph => "2^num_arcs", + default MinimumWeightAndOrGraph => "2^num_arcs" create MinimumWeightAndOrGraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/multiprocessor_scheduling.rs b/src/models/misc/multiprocessor_scheduling.rs index 4339a99ce..65d9ff2ca 100644 --- a/src/models/misc/multiprocessor_scheduling.rs +++ b/src/models/misc/multiprocessor_scheduling.rs @@ -4,7 +4,7 @@ //! can be assigned to identical processors such that no processor's //! total load exceeds a given deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign tasks to processors so that no processor's load exceeds a deadline", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadline", type_name: "u64", description: "Global deadline D" }, - ], + fields: MultiprocessorSchedulingCreateSpec::FIELDS, } } @@ -63,6 +59,25 @@ pub struct MultiprocessorScheduling { deadline: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultiprocessorSchedulingCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Number of identical processors. + num_processors: usize, + /// Global deadline. + deadline: u64, +} +impl TryFrom for MultiprocessorScheduling { + type Error = String; + fn try_from(spec: MultiprocessorSchedulingCreateSpec) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + Ok(Self::new(spec.lengths, spec.num_processors, spec.deadline)) + } +} + impl MultiprocessorScheduling { /// Create a new Multiprocessor Scheduling instance. /// @@ -134,7 +149,7 @@ impl Problem for MultiprocessorScheduling { } crate::declare_variants! { - default MultiprocessorScheduling => "2^num_tasks", + default MultiprocessorScheduling => "2^num_tasks" create MultiprocessorSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 3db688a38..9cbb33bcc 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -6,7 +6,7 @@ //! both machine capacity (one job at a time per machine) and job capacity //! (each job uses at most one machine at a time) constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,10 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize the makespan of an open-shop schedule", - fields: &[ - FieldInfo { name: "num_machines", type_name: "usize", description: "Number of machines m" }, - FieldInfo { name: "processing_times", type_name: "Vec>", description: "processing_times[j][i] = processing time of job j on machine i (n x m)" }, - ], + fields: OpenShopSchedulingCreateSpec::FIELDS, } } @@ -69,6 +66,31 @@ pub struct OpenShopScheduling { processing_times: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OpenShopSchedulingCreateSpec { + /// Number of machines m. + num_processors: usize, + /// Processing time of each job on each machine (n x m). + processing_times: Vec>, +} + +impl TryFrom for OpenShopScheduling { + type Error = String; + + fn try_from(spec: OpenShopSchedulingCreateSpec) -> Result { + for (job, times) in spec.processing_times.iter().enumerate() { + if times.len() != spec.num_processors { + return Err(format!( + "processing_times[{job}] has {} entries, expected {}", + times.len(), + spec.num_processors + )); + } + } + Ok(Self::new(spec.num_processors, spec.processing_times)) + } +} + impl OpenShopScheduling { /// Create a new Open Shop Scheduling instance. /// @@ -222,7 +244,7 @@ impl Problem for OpenShopScheduling { } crate::declare_variants! { - default OpenShopScheduling => "factorial(num_jobs)^num_machines", + default OpenShopScheduling => "factorial(num_jobs)^num_machines" create OpenShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/optimum_communication_spanning_tree.rs b/src/models/misc/optimum_communication_spanning_tree.rs index 6f2729b9a..41c0f7631 100644 --- a/src/models/misc/optimum_communication_spanning_tree.rs +++ b/src/models/misc/optimum_communication_spanning_tree.rs @@ -5,7 +5,7 @@ //! minimizes the total communication cost: sum_{u>", description: "Symmetric weight matrix w(i,j)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Symmetric requirement matrix r(i,j)" }, - ], + fields: OptimumCommunicationSpanningTreeCreateSpec::FIELDS, } } @@ -72,6 +68,49 @@ pub struct OptimumCommunicationSpanningTree { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OptimumCommunicationSpanningTreeCreateSpec { + /// Number of vertices. + num_vertices: usize, + /// Symmetric weight matrix; defaults to unit off-diagonal weights. + edge_weights: Option>>, + /// Symmetric communication requirement matrix. + requirements: Vec>, +} +impl TryFrom for OptimumCommunicationSpanningTree { + type Error = String; + fn try_from(spec: OptimumCommunicationSpanningTreeCreateSpec) -> Result { + let n = spec.num_vertices; + if n < 2 { + return Err("must have at least two vertices".to_string()); + } + let edge_weights = spec.edge_weights.unwrap_or_else(|| { + (0..n) + .map(|i| (0..n).map(|j| i32::from(i != j)).collect()) + .collect() + }); + for (name, matrix) in [ + ("edge_weights", &edge_weights), + ("requirements", &spec.requirements), + ] { + if matrix.len() != n || matrix.iter().any(|row| row.len() != n) { + return Err(format!("{name} must be a {n} x {n} matrix")); + } + for (i, row) in matrix.iter().enumerate() { + if row[i] != 0 { + return Err(format!("{name} diagonal must be zero")); + } + for (j, &value) in row.iter().enumerate().skip(i + 1) { + if value != matrix[j][i] || value < 0 { + return Err(format!("{name} must be symmetric and nonnegative")); + } + } + } + } + Ok(Self::new(edge_weights, spec.requirements)) + } +} + impl OptimumCommunicationSpanningTree { /// Create a new OptimumCommunicationSpanningTree instance. /// @@ -312,7 +351,7 @@ impl Problem for OptimumCommunicationSpanningTree { } crate::declare_variants! { - default OptimumCommunicationSpanningTree => "2^num_edges", + default OptimumCommunicationSpanningTree => "2^num_edges" create OptimumCommunicationSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/partially_ordered_knapsack.rs b/src/models/misc/partially_ordered_knapsack.rs index 405dab450..e70e3be46 100644 --- a/src/models/misc/partially_ordered_knapsack.rs +++ b/src/models/misc/partially_ordered_knapsack.rs @@ -4,7 +4,7 @@ //! an item requires including all its predecessors (downward-closed set). //! NP-complete in the strong sense (Garey & Johnson, A6 MP12). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Select items to maximize total value subject to precedence constraints and weight capacity", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Item weights w(u) for each item" }, - FieldInfo { name: "values", type_name: "Vec", description: "Item values v(u) for each item" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (a, b) meaning a must be included before b" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Knapsack capacity B" }, - ], + fields: PartiallyOrderedKnapsackCreateSpec::FIELDS, } } @@ -76,6 +71,69 @@ pub struct PartiallyOrderedKnapsack { predecessors: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartiallyOrderedKnapsackCreateSpec { + weights: Vec, + values: Vec, + precedences: Option>, + capacity: i64, +} + +impl TryFrom for PartiallyOrderedKnapsack { + type Error = String; + + fn try_from(spec: PartiallyOrderedKnapsackCreateSpec) -> Result { + if spec.weights.len() != spec.values.len() { + return Err("weights and values must have the same length".to_string()); + } + if spec.capacity < 0 { + return Err("capacity must be non-negative".to_string()); + } + if let Some((index, weight)) = spec + .weights + .iter() + .enumerate() + .find(|(_, weight)| **weight < 0) + { + return Err(format!( + "weight[{index}] must be non-negative, got {weight}" + )); + } + if let Some((index, value)) = spec + .values + .iter() + .enumerate() + .find(|(_, value)| **value < 0) + { + return Err(format!("value[{index}] must be non-negative, got {value}")); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_items = spec.weights.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_items || succ >= num_items) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_items} items" + )); + } + let predecessors = Self::compute_predecessors(&precedences, num_items); + if let Some(item) = predecessors + .iter() + .enumerate() + .find_map(|(item, preds)| preds.contains(&item).then_some(item)) + { + return Err(format!("precedences contain a cycle involving item {item}")); + } + Ok(Self::new( + spec.weights, + spec.values, + precedences, + spec.capacity, + )) + } +} + impl Serialize for PartiallyOrderedKnapsack { fn serialize(&self, serializer: S) -> Result { PartiallyOrderedKnapsackRaw { @@ -266,7 +324,7 @@ impl Problem for PartiallyOrderedKnapsack { } crate::declare_variants! { - default PartiallyOrderedKnapsack => "2^num_items", + default PartiallyOrderedKnapsack => "2^num_items" create PartiallyOrderedKnapsackCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index e5c887e07..b46dcc5f5 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -4,7 +4,7 @@ //! deadline D, determine whether all tasks can be scheduled to meet D while //! respecting precedences. NP-complete via reduction from 3SAT (Ullman, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,12 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule unit-length tasks on m processors by deadline D respecting precedence constraints", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks n = |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "deadline", type_name: "usize", description: "Global deadline D" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (i, j) meaning task i must finish before task j starts" }, - ], + fields: PrecedenceConstrainedSchedulingCreateSpec::FIELDS, } } @@ -58,6 +53,43 @@ pub struct PrecedenceConstrainedScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrecedenceConstrainedSchedulingCreateSpec { + num_tasks: usize, + num_processors: usize, + deadline: usize, + precedences: Option>, +} + +impl TryFrom for PrecedenceConstrainedScheduling { + type Error = String; + + fn try_from(spec: PrecedenceConstrainedSchedulingCreateSpec) -> Result { + if spec.num_tasks > 0 && spec.num_processors == 0 { + return Err("num_processors must be positive when there are tasks".to_string()); + } + if spec.num_tasks > 0 && spec.deadline == 0 { + return Err("deadline must be positive when there are tasks".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= spec.num_tasks || succ >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadline, + precedences, + )) + } +} + impl PrecedenceConstrainedScheduling { /// Create a new Precedence Constrained Scheduling instance. /// @@ -157,7 +189,7 @@ impl Problem for PrecedenceConstrainedScheduling { } crate::declare_variants! { - default PrecedenceConstrainedScheduling => "2^num_tasks", + default PrecedenceConstrainedScheduling => "2^num_tasks" create PrecedenceConstrainedSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/preemptive_scheduling.rs b/src/models/misc/preemptive_scheduling.rs index cad0d78ee..fbe1d98e3 100644 --- a/src/models/misc/preemptive_scheduling.rs +++ b/src/models/misc/preemptive_scheduling.rs @@ -5,7 +5,7 @@ //! `m` identical processors, subject to precedence constraints. //! The goal is to minimize the makespan (latest completion time). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Minimize makespan for preemptive parallel-processor scheduling with precedence constraints", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (pred, succ) — pred must finish before succ starts" }, - ], + fields: PreemptiveSchedulingCreateSpec::FIELDS, } } @@ -68,6 +64,23 @@ pub struct PreemptiveScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PreemptiveSchedulingCreateSpec { + lengths: Vec, + num_processors: usize, + precedences: Option>, +} + +impl TryFrom for PreemptiveScheduling { + type Error = String; + + fn try_from(spec: PreemptiveSchedulingCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, spec.num_processors, &precedences)?; + Ok(Self::new(spec.lengths, spec.num_processors, precedences)) + } +} + #[derive(Deserialize)] struct PreemptiveSchedulingSerde { lengths: Vec, @@ -244,7 +257,7 @@ impl Problem for PreemptiveScheduling { } crate::declare_variants! { - default PreemptiveScheduling => "2^(num_tasks * num_tasks)", + default PreemptiveScheduling => "2^(num_tasks * num_tasks)" create PreemptiveSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/production_planning.rs b/src/models/misc/production_planning.rs index 914df0d3b..e670d35e5 100644 --- a/src/models/misc/production_planning.rs +++ b/src/models/misc/production_planning.rs @@ -5,7 +5,7 @@ //! exists a feasible production plan that satisfies all demand without //! backlogging and stays within budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Or; use serde::{Deserialize, Serialize}; @@ -18,15 +18,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether a multi-period production plan can satisfy all demand within a cost bound", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of planning periods n" }, - FieldInfo { name: "demands", type_name: "Vec", description: "Demand r_i for each period" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Production capacity c_i for each period" }, - FieldInfo { name: "setup_costs", type_name: "Vec", description: "Setup cost b_i incurred when x_i > 0" }, - FieldInfo { name: "production_costs", type_name: "Vec", description: "Per-unit production cost coefficient p_i" }, - FieldInfo { name: "inventory_costs", type_name: "Vec", description: "Per-unit inventory cost coefficient h_i" }, - FieldInfo { name: "cost_bound", type_name: "u64", description: "Total cost bound B" }, - ], + fields: ProductionPlanningCreateSpec::FIELDS, } } @@ -42,6 +34,63 @@ pub struct ProductionPlanning { cost_bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ProductionPlanningCreateSpec { + /// Number of planning periods. + num_periods: usize, + /// Demand per period. + demands: Vec, + /// Production capacity per period. + capacities: Vec, + /// Setup cost per period. + setup_costs: Vec, + /// Per-unit production cost per period. + production_costs: Vec, + /// Per-unit inventory cost per period. + inventory_costs: Vec, + /// Total cost bound. + cost_bound: u64, +} +impl TryFrom for ProductionPlanning { + type Error = String; + fn try_from(spec: ProductionPlanningCreateSpec) -> Result { + if spec.num_periods == 0 { + return Err("num_periods must be positive".to_string()); + } + for (name, len) in [ + ("demands", spec.demands.len()), + ("capacities", spec.capacities.len()), + ("setup_costs", spec.setup_costs.len()), + ("production_costs", spec.production_costs.len()), + ("inventory_costs", spec.inventory_costs.len()), + ] { + if len != spec.num_periods { + return Err(format!( + "{name} has {len} entries, expected {}", + spec.num_periods + )); + } + } + if spec.capacities.iter().any(|&capacity| { + usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + }) { + return Err("capacities must fit in usize for dims()".to_string()); + } + Ok(Self::new( + spec.num_periods, + spec.demands, + spec.capacities, + spec.setup_costs, + spec.production_costs, + spec.inventory_costs, + spec.cost_bound, + )) + } +} + impl ProductionPlanning { pub fn new( num_periods: usize, @@ -185,7 +234,7 @@ impl Problem for ProductionPlanning { } crate::declare_variants! { - default ProductionPlanning => "(max_capacity + 1)^num_periods", + default ProductionPlanning => "(max_capacity + 1)^num_periods" create ProductionPlanningCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs index 15c6d1895..24ac4fcb0 100644 --- a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -6,7 +6,7 @@ //! completion time. Within each processor, tasks are ordered by Smith's //! rule (non-decreasing length-to-weight ratio). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,11 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign tasks to processors to minimize total weighted completion time (Smith's rule ordering)", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - ], + fields: SchedulingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -65,6 +61,34 @@ pub struct SchedulingToMinimizeWeightedCompletionTime { num_processors: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingToMinimizeWeightedCompletionTimeCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Number of identical processors. + num_processors: usize, +} +impl TryFrom + for SchedulingToMinimizeWeightedCompletionTime +{ + type Error = String; + fn try_from( + spec: SchedulingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + let count = spec.lengths.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.num_processors)) + } +} + fn serialize_num_processors(v: &usize, s: S) -> Result { s.serialize_u64(*v as u64) } @@ -222,7 +246,7 @@ impl Problem for SchedulingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks", + default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks" create SchedulingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/scheduling_with_individual_deadlines.rs b/src/models/misc/scheduling_with_individual_deadlines.rs index 391ca772b..f48c82a06 100644 --- a/src/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/models/misc/scheduling_with_individual_deadlines.rs @@ -4,7 +4,7 @@ //! determine whether they can be scheduled on `m` identical processors so that //! every task finishes by its own deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether unit-length tasks can be scheduled on m processors while meeting individual deadlines", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SchedulingWithIndividualDeadlinesCreateSpec::FIELDS, } } @@ -40,6 +35,46 @@ pub struct SchedulingWithIndividualDeadlines { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingWithIndividualDeadlinesCreateSpec { + /// Number of tasks. + num_tasks: usize, + /// Number of identical processors. + num_processors: usize, + /// Deadline for each task. + deadlines: Vec, + /// Precedence pairs. + precedences: Option>, +} +impl TryFrom for SchedulingWithIndividualDeadlines { + type Error = String; + fn try_from(spec: SchedulingWithIndividualDeadlinesCreateSpec) -> Result { + if spec.deadlines.len() != spec.num_tasks { + return Err(format!( + "deadlines has {} entries, expected {}", + spec.deadlines.len(), + spec.num_tasks + )); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(p, s)| p >= spec.num_tasks || s >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadlines, + precedences, + )) + } +} + impl SchedulingWithIndividualDeadlines { pub fn new( num_tasks: usize, @@ -145,7 +180,7 @@ impl Problem for SchedulingWithIndividualDeadlines { } crate::declare_variants! { - default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks", + default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks" create SchedulingWithIndividualDeadlinesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index 46627bfcf..b34045d65 100644 --- a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -4,7 +4,7 @@ //! a valid one-machine schedule that minimizes the maximum cumulative cost //! over all prefixes. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::de::Error as _; use serde::{Deserialize, Serialize}; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with precedence constraints to minimize the maximum cumulative cost prefix", - fields: &[ - FieldInfo { name: "costs", type_name: "Vec", description: "Task costs in schedule order-independent indexing" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingCumulativeCostCreateSpec::FIELDS, } } @@ -40,6 +37,30 @@ pub struct SequencingToMinimizeMaximumCumulativeCost { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingCumulativeCostCreateSpec { + /// Task costs. + #[create(codec = "comma-separated")] + costs: Vec, + /// Precedence arcs; omitted means no constraints. + #[create(codec = "arc-list")] + precedences: Option>, +} + +impl TryFrom for SequencingToMinimizeMaximumCumulativeCost { + type Error = String; + fn try_from(spec: SequencingCumulativeCostCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + if let Some(message) = precedence_validation_error(&precedences, spec.costs.len()) { + return Err(message); + } + Ok(Self { + costs: spec.costs, + precedences, + }) + } +} + #[derive(Debug, Deserialize)] struct SequencingToMinimizeMaximumCumulativeCostUnchecked { costs: Vec, @@ -165,7 +186,7 @@ impl Problem for SequencingToMinimizeMaximumCumulativeCost { } crate::declare_variants! { - default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)", + default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)" create SequencingCumulativeCostCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index 379dac87d..3bd89bba6 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -4,7 +4,7 @@ //! Garey & Johnson, 1979) where tasks with processing times, weights, //! and deadlines must be scheduled to minimize the total weight of tardy tasks. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,11 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with lengths, weights, and deadlines to minimize total weight of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - ], + fields: SequencingToMinimizeTardyTaskWeightCreateSpec::FIELDS, } } @@ -44,6 +40,32 @@ pub struct SequencingToMinimizeTardyTaskWeight { deadlines: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeTardyTaskWeightCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Deadline for each task. + deadlines: Vec, +} +impl TryFrom + for SequencingToMinimizeTardyTaskWeight +{ + type Error = String; + fn try_from(spec: SequencingToMinimizeTardyTaskWeightCreateSpec) -> Result { + let count = spec.lengths.len(); + if spec.deadlines.len() != count { + return Err("deadlines length must equal lengths length".to_string()); + } + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.deadlines)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeTardyTaskWeightSerde { lengths: Vec, @@ -166,7 +188,7 @@ impl Problem for SequencingToMinimizeTardyTaskWeight { } crate::declare_variants! { - default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)", + default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)" create SequencingToMinimizeTardyTaskWeightCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 6a62e11eb..4390ecbe6 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -10,7 +10,7 @@ //! Optimal Linear Arrangement, which uses zero-length edge jobs instead //! of padding them to unit length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -23,11 +23,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks with lengths, weights, and precedence constraints to minimize total weighted completion time", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -46,6 +42,27 @@ pub struct SequencingToMinimizeWeightedCompletionTime { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: Vec, + weights: Vec, + precedences: Option>, +} + +impl TryFrom + for SequencingToMinimizeWeightedCompletionTime +{ + type Error = String; + + fn try_from( + spec: SequencingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, &spec.weights, &precedences)?; + Ok(Self::new(spec.lengths, spec.weights, precedences)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeWeightedCompletionTimeSerde { lengths: Vec, @@ -215,7 +232,7 @@ impl Problem for SequencingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)", + default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)" create SequencingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 946ae2140..46d2c0aa7 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -5,7 +5,7 @@ //! total weighted tardiness is at most a given bound. //! Corresponds to scheduling notation `1 || sum w_j T_j`. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -17,12 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule jobs on one machine so total weighted tardiness is at most K", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing times l_j for each job" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Tardiness weights w_j for each job" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines d_j for each job" }, - FieldInfo { name: "bound", type_name: "u64", description: "Upper bound K on total weighted tardiness" }, - ], + fields: SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS, } } @@ -63,6 +58,39 @@ pub struct SequencingToMinimizeWeightedTardiness { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedTardinessCreateSpec { + /// Processing times for each job. + lengths: Vec, + /// Tardiness weights for each job. + weights: Vec, + /// Deadlines for each job. + deadlines: Vec, + /// Upper bound on total weighted tardiness. + bound: u64, +} +impl TryFrom + for SequencingToMinimizeWeightedTardiness +{ + type Error = String; + fn try_from( + spec: SequencingToMinimizeWeightedTardinessCreateSpec, + ) -> Result { + if spec.lengths.len() != spec.weights.len() { + return Err("weights length must equal lengths length".to_string()); + } + if spec.lengths.len() != spec.deadlines.len() { + return Err("deadlines length must equal lengths length".to_string()); + } + Ok(Self::new( + spec.lengths, + spec.weights, + spec.deadlines, + spec.bound, + )) + } +} + impl SequencingToMinimizeWeightedTardiness { /// Create a new weighted tardiness scheduling instance. /// @@ -159,7 +187,7 @@ impl Problem for SequencingToMinimizeWeightedTardiness { } crate::declare_variants! { - default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)", + default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)" create SequencingToMinimizeWeightedTardinessCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index cc391444a..8534f5019 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -4,7 +4,7 @@ //! determine whether all tasks can be scheduled non-overlappingly such that each //! task runs entirely within its allowed time window. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Schedule tasks non-overlappingly within their time windows", - fields: &[ - FieldInfo { name: "release_times", type_name: "Vec", description: "Release time r(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - ], + fields: SequencingWithinIntervalsCreateSpec::FIELDS, } } @@ -63,6 +59,36 @@ pub struct SequencingWithinIntervals { lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingWithinIntervalsCreateSpec { + /// Release times. + release_times: Vec, + /// Deadlines. + deadlines: Vec, + /// Processing lengths. + lengths: Vec, +} +impl TryFrom for SequencingWithinIntervals { + type Error = String; + fn try_from(spec: SequencingWithinIntervalsCreateSpec) -> Result { + if spec.release_times.len() != spec.deadlines.len() { + return Err("release_times and deadlines must have the same length".to_string()); + } + if spec.release_times.len() != spec.lengths.len() { + return Err("release_times and lengths must have the same length".to_string()); + } + for index in 0..spec.release_times.len() { + let finish = spec.release_times[index] + .checked_add(spec.lengths[index]) + .ok_or_else(|| format!("task {index} release time plus length overflows u64"))?; + if finish > spec.deadlines[index] { + return Err(format!("task {index} has an empty time window")); + } + } + Ok(Self::new(spec.release_times, spec.deadlines, spec.lengths)) + } +} + impl SequencingWithinIntervals { /// Create a new SequencingWithinIntervals problem. /// @@ -173,7 +199,7 @@ impl Problem for SequencingWithinIntervals { } crate::declare_variants! { - default SequencingWithinIntervals => "2^num_tasks", + default SequencingWithinIntervals => "2^num_tasks" create SequencingWithinIntervalsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/shortest_common_supersequence.rs b/src/models/misc/shortest_common_supersequence.rs index b6d6bafee..03204134f 100644 --- a/src/models/misc/shortest_common_supersequence.rs +++ b/src/models/misc/shortest_common_supersequence.rs @@ -12,7 +12,7 @@ //! lengths (the worst case where no overlap exists). This problem is NP-hard //! (Maier, 1978). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -25,11 +25,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a shortest common supersequence for a set of strings", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "strings", type_name: "Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible supersequence length (sum of all string lengths)" }, - ], + fields: ShortestCommonSupersequenceCreateSpec::FIELDS, } } @@ -65,6 +61,48 @@ pub struct ShortestCommonSupersequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestCommonSupersequenceCreateSpec { + /// Input strings; the alphabet and maximum length are inferred from them. + #[create(codec = "semicolon-separated")] + strings: Vec>, +} + +impl TryFrom for ShortestCommonSupersequence { + type Error = String; + + fn try_from(spec: ShortestCommonSupersequenceCreateSpec) -> Result { + if spec.strings.is_empty() { + return Err("must have at least one string".to_string()); + } + + let alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let max_length = spec.strings.iter().try_fold(0_usize, |total, string| { + total + .checked_add(string.len()) + .ok_or_else(|| "maximum supersequence length overflows usize".to_string()) + })?; + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl ShortestCommonSupersequence { /// Create a new ShortestCommonSupersequence instance. /// @@ -179,7 +217,7 @@ impl Problem for ShortestCommonSupersequence { } crate::declare_variants! { - default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length", + default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length" create ShortestCommonSupersequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index e1b835d28..453266759 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -4,7 +4,7 @@ //! walk that traverses every required arc in some order and minimizes the //! total route length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -19,13 +19,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a closed walk that traverses each required directed arc and minimizes total length", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the mixed graph" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Required directed arcs that must be traversed" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Undirected edges available for connector paths" }, - FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Nonnegative lengths of the required directed arcs" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Nonnegative lengths of the undirected connector edges" }, - ], + fields: StackerCraneCreateSpec::FIELDS, } } @@ -46,6 +40,83 @@ pub struct StackerCrane { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StackerCraneCreateSpec { + /// Required directed arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Undirected connector edges. + #[create(name = "graph", codec = "edge-list")] + edges: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Required-arc lengths; defaults to one per arc. + #[create(codec = "comma-separated")] + arc_lengths: Option>, + /// Connector-edge lengths; defaults to one per edge. + #[create(codec = "comma-separated")] + edge_lengths: Option>, +} + +impl TryFrom for StackerCrane { + type Error = String; + + fn try_from(spec: StackerCraneCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.edges.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred_arcs = inferred_vertex_count(&spec.arcs)?; + let inferred_edges = inferred_vertex_count(&spec.edges)?; + let num_vertices = match spec.num_vertices { + Some(count) => count, + None if inferred_arcs == inferred_edges => inferred_arcs, + None => { + return Err(format!( + "directed and undirected inputs infer different vertex counts ({inferred_arcs} and {inferred_edges}); provide num_vertices" + )) + } + }; + if num_vertices < inferred_arcs || num_vertices < inferred_edges { + return Err(format!( + "num_vertices {num_vertices} is too small for the provided endpoints" + )); + } + let arc_lengths = spec.arc_lengths.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let edge_lengths = spec + .edge_lengths + .unwrap_or_else(|| vec![1; spec.edges.len()]); + Self::try_new( + num_vertices, + spec.arcs, + spec.edges, + arc_lengths, + edge_lengths, + ) + } +} + +fn inferred_vertex_count(pairs: &[(usize, usize)]) -> Result { + pairs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| { + vertex + .checked_add(1) + .ok_or("vertex count overflows usize".to_string()) + }) + .transpose() + .map(|count| count.unwrap_or(0)) +} + impl StackerCrane { /// Create a new Stacker Crane instance. /// @@ -267,7 +338,7 @@ impl Problem for StackerCrane { } crate::declare_variants! { - default StackerCrane => "num_vertices^2 * 2^num_arcs", + default StackerCrane => "num_vertices^2 * 2^num_arcs" create StackerCraneCreateSpec, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/models/misc/staff_scheduling.rs b/src/models/misc/staff_scheduling.rs index 7db6f75be..990063e77 100644 --- a/src/models/misc/staff_scheduling.rs +++ b/src/models/misc/staff_scheduling.rs @@ -4,7 +4,7 @@ //! worker budget, determine whether workers can be assigned to schedules so that //! all requirements are met without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,12 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign workers to schedule patterns to satisfy per-period staffing requirements within a worker budget", - fields: &[ - FieldInfo { name: "shifts_per_schedule", type_name: "usize", description: "Required number of active periods in each schedule pattern" }, - FieldInfo { name: "schedules", type_name: "Vec>", description: "Binary schedule patterns available to workers" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Minimum staffing requirement for each period" }, - FieldInfo { name: "num_workers", type_name: "u64", description: "Maximum number of workers available" }, - ], + fields: StaffSchedulingCreateSpec::FIELDS, } } @@ -38,6 +33,50 @@ pub struct StaffScheduling { num_workers: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StaffSchedulingCreateSpec { + /// Required number of active periods in each schedule pattern. + k: usize, + /// Binary schedule patterns available to workers. + schedules: Vec>, + /// Minimum staffing requirement for each period. + requirements: Vec, + /// Maximum number of workers available. + num_workers: u64, +} + +impl TryFrom for StaffScheduling { + type Error = String; + + fn try_from(spec: StaffSchedulingCreateSpec) -> Result { + if spec.num_workers >= usize::MAX as u64 { + return Err("num_workers must be smaller than usize::MAX".to_string()); + } + for (schedule_index, schedule) in spec.schedules.iter().enumerate() { + if schedule.len() != spec.requirements.len() { + return Err(format!( + "schedules[{schedule_index}] has {} periods, expected {}", + schedule.len(), + spec.requirements.len() + )); + } + let active_periods = schedule.iter().filter(|&&active| active).count(); + if active_periods != spec.k { + return Err(format!( + "schedules[{schedule_index}] has {active_periods} active periods, expected {}", + spec.k + )); + } + } + Ok(Self::new( + spec.k, + spec.schedules, + spec.requirements, + spec.num_workers, + )) + } +} + impl StaffScheduling { /// Create a new Staff Scheduling instance. /// @@ -173,7 +212,7 @@ impl Problem for StaffScheduling { } crate::declare_variants! { - default StaffScheduling => "(num_workers + 1)^num_schedules", + default StaffScheduling => "(num_workers + 1)^num_schedules" create StaffSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/string_to_string_correction.rs b/src/models/misc/string_to_string_correction.rs index 30f02a3d0..f884cc522 100644 --- a/src/models/misc/string_to_string_correction.rs +++ b/src/models/misc/string_to_string_correction.rs @@ -14,7 +14,7 @@ //! //! This problem is NP-complete (Wagner, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -26,12 +26,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Derive target string from source using at most K deletions and adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the finite alphabet" }, - FieldInfo { name: "source", type_name: "Vec", description: "Source string (symbol indices)" }, - FieldInfo { name: "target", type_name: "Vec", description: "Target string (symbol indices)" }, - FieldInfo { name: "bound", type_name: "usize", description: "Maximum number of operations allowed" }, - ], + fields: StringToStringCorrectionCreateSpec::FIELDS, } } @@ -77,6 +72,59 @@ pub struct StringToStringCorrection { bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StringToStringCorrectionCreateSpec { + /// Optional alphabet size; omitted values are inferred from both strings. + alphabet_size: Option, + /// Source string. + #[create(codec = "comma-separated")] + source_string: Vec, + /// Target string. + #[create(codec = "comma-separated")] + target_string: Vec, + /// Maximum number of correction operations. + bound: usize, +} + +impl TryFrom for StringToStringCorrection { + type Error = String; + + fn try_from(spec: StringToStringCorrectionCreateSpec) -> Result { + let inferred_alphabet_size = spec + .source_string + .iter() + .chain(&spec.target_string) + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && (!spec.source_string.is_empty() || !spec.target_string.is_empty()) + { + return Err( + "alphabet size must be positive when either string is non-empty".to_string(), + ); + } + + Ok(Self { + alphabet_size, + source: spec.source_string, + target: spec.target_string, + bound: spec.bound, + }) + } +} + impl StringToStringCorrection { /// Create a new StringToStringCorrection instance. /// @@ -191,7 +239,7 @@ impl Problem for StringToStringCorrection { } crate::declare_variants! { - default StringToStringCorrection => "(2 * source_length + 1) ^ bound", + default StringToStringCorrection => "(2 * source_length + 1) ^ bound" create StringToStringCorrectionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/three_partition.rs b/src/models/misc/three_partition.rs index 9131544d4..47c3f26e9 100644 --- a/src/models/misc/three_partition.rs +++ b/src/models/misc/three_partition.rs @@ -3,7 +3,7 @@ //! Given 3m positive integers that each lie strictly between B/4 and B/2, //! determine whether they can be partitioned into m triples that all sum to B. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Or; use serde::de::Error as _; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Partition 3m bounded positive integers into m triples whose sums all equal B", - fields: &[ - FieldInfo { name: "sizes", type_name: "Vec", description: "Positive integer sizes s(a) for each element a in A" }, - FieldInfo { name: "bound", type_name: "u64", description: "Target sum B for each triple" }, - ], + fields: ThreePartitionCreateSpec::FIELDS, } } @@ -135,19 +132,30 @@ impl ThreePartition { } } -#[derive(Deserialize)] -struct ThreePartitionData { +#[derive(Deserialize, crate::CreateSpec)] +struct ThreePartitionCreateSpec { + /// Positive integer sizes for the elements to partition. + #[create(codec = "comma-separated")] sizes: Vec, + /// Target sum for each triple. bound: u64, } +impl TryFrom for ThreePartition { + type Error = String; + + fn try_from(spec: ThreePartitionCreateSpec) -> Result { + Self::try_new(spec.sizes, spec.bound) + } +} + impl<'de> Deserialize<'de> for ThreePartition { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let data = ThreePartitionData::deserialize(deserializer)?; - Self::try_new(data.sizes, data.bound).map_err(D::Error::custom) + let spec = ThreePartitionCreateSpec::deserialize(deserializer)?; + Self::try_from(spec).map_err(D::Error::custom) } } @@ -176,7 +184,7 @@ impl Problem for ThreePartition { } crate::declare_variants! { - default ThreePartition => "3^num_elements", + default ThreePartition => "3^num_elements" create ThreePartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index 1235d53b1..94f687ac5 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -4,7 +4,7 @@ //! respecting availability, per-period exclusivity, and exact pairwise work //! requirements. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,14 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Assign craftsmen to tasks over work periods subject to availability and exact pairwise requirements", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of work periods |H|" }, - FieldInfo { name: "num_craftsmen", type_name: "usize", description: "Number of craftsmen |C|" }, - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "craftsman_avail", type_name: "Vec>", description: "Availability matrix A(c) for craftsmen (|C| x |H|)" }, - FieldInfo { name: "task_avail", type_name: "Vec>", description: "Availability matrix A(t) for tasks (|T| x |H|)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Required work periods R(c,t) for each craftsman-task pair (|C| x |T|)" }, - ], + fields: TimetableDesignCreateSpec::FIELDS, } } @@ -42,6 +35,92 @@ pub struct TimetableDesign { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TimetableDesignCreateSpec { + /// Number of work periods. + num_periods: usize, + /// Number of craftsmen. + num_craftsmen: usize, + /// Number of tasks. + num_tasks: usize, + /// Craftsman availability matrix. + craftsman_avail: Vec>, + /// Task availability matrix. + task_avail: Vec>, + /// Required work periods for each craftsman-task pair. + requirements: Vec>, +} +impl TryFrom for TimetableDesign { + type Error = String; + fn try_from(spec: TimetableDesignCreateSpec) -> Result { + if spec.craftsman_avail.len() != spec.num_craftsmen { + return Err(format!( + "craftsman_avail has {} rows, expected {}", + spec.craftsman_avail.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .craftsman_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "craftsman_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.task_avail.len() != spec.num_tasks { + return Err(format!( + "task_avail has {} rows, expected {}", + spec.task_avail.len(), + spec.num_tasks + )); + } + if let Some((index, row)) = spec + .task_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "task_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.requirements.len() != spec.num_craftsmen { + return Err(format!( + "requirements has {} rows, expected {}", + spec.requirements.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .requirements + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_tasks) + { + return Err(format!( + "requirements row {index} has {} tasks, expected {}", + row.len(), + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_periods, + spec.num_craftsmen, + spec.num_tasks, + spec.craftsman_avail, + spec.task_avail, + spec.requirements, + )) + } +} + impl TimetableDesign { /// Create a new Timetable Design instance. /// @@ -355,7 +434,7 @@ impl Problem for TimetableDesign { } crate::declare_variants! { - default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)", + default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)" create TimetableDesignCreateSpec, } #[cfg(any(test, feature = "example-db"))] diff --git a/src/models/set/comparative_containment.rs b/src/models/set/comparative_containment.rs index c1ad0629f..94d510c94 100644 --- a/src/models/set/comparative_containment.rs +++ b/src/models/set/comparative_containment.rs @@ -4,7 +4,7 @@ //! whether there exists a subset of the universe whose containment weight //! in the first family is at least its containment weight in the second. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{One, WeightElement}; use num_traits::Zero; @@ -25,13 +25,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["One", "i32", "f64"])], module_path: module_path!(), description: "Compare containment-weight sums for two set families over a shared universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe X" }, - FieldInfo { name: "r_sets", type_name: "Vec>", description: "First set family R over X" }, - FieldInfo { name: "s_sets", type_name: "Vec>", description: "Second set family S over X" }, - FieldInfo { name: "r_weights", type_name: "Vec", description: "Positive weights for sets in R" }, - FieldInfo { name: "s_weights", type_name: "Vec", description: "Positive weights for sets in S" }, - ], + fields: ComparativeContainmentI32CreateSpec::FIELDS, } } @@ -50,6 +44,88 @@ pub struct ComparativeContainment { s_weights: Vec, } +macro_rules! comparative_containment_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Size of the common universe. + universe_size: usize, + /// First set family. + #[create(codec = "semicolon-separated")] + r_sets: Vec>, + /// Second set family. + #[create(codec = "semicolon-separated")] + s_sets: Vec>, + /// Positive weights for the first family; defaults to one. + #[create(codec = "comma-separated")] + r_weights: Option>, + /// Positive weights for the second family; defaults to one. + #[create(codec = "comma-separated")] + s_weights: Option>, + } + + impl TryFrom<$name> for ComparativeContainment<$weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + validate_create_set_family("R", spec.universe_size, &spec.r_sets)?; + validate_create_set_family("S", spec.universe_size, &spec.s_sets)?; + let r_weights = spec + .r_weights + .unwrap_or_else(|| vec![$one; spec.r_sets.len()]); + let s_weights = spec + .s_weights + .unwrap_or_else(|| vec![$one; spec.s_sets.len()]); + validate_create_weights("R", spec.r_sets.len(), &r_weights)?; + validate_create_weights("S", spec.s_sets.len(), &s_weights)?; + Ok(ComparativeContainment { + universe_size: spec.universe_size, + r_sets: spec.r_sets, + s_sets: spec.s_sets, + r_weights, + s_weights, + }) + } + } + }; +} + +fn validate_create_set_family( + label: &str, + universe_size: usize, + sets: &[Vec], +) -> Result<(), String> { + for (set_index, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe_size { + return Err(format!("{label} set {set_index} contains element {element} outside universe of size {universe_size}")); + } + } + } + Ok(()) +} + +fn validate_create_weights( + label: &str, + count: usize, + weights: &[W], +) -> Result<(), String> { + if weights.len() != count { + return Err(format!("number of {label} sets and weights must match")); + } + for (index, weight) in weights.iter().enumerate() { + if weight.to_sum().partial_cmp(&W::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err(format!( + "{label} weight at index {index} must be finite and positive" + )); + } + } + Ok(()) +} + +comparative_containment_create_spec!(ComparativeContainmentI32CreateSpec, i32, 1_i32); +comparative_containment_create_spec!(ComparativeContainmentF64CreateSpec, f64, 1.0_f64); +comparative_containment_create_spec!(ComparativeContainmentOneCreateSpec, One, One); + impl ComparativeContainment { /// Create a new instance with unit weights. pub fn new(universe_size: usize, r_sets: Vec>, s_sets: Vec>) -> Self @@ -200,9 +276,9 @@ where } crate::declare_variants! { - ComparativeContainment => "2^universe_size", - default ComparativeContainment => "2^universe_size", - ComparativeContainment => "2^universe_size", + ComparativeContainment => "2^universe_size" create ComparativeContainmentOneCreateSpec, + default ComparativeContainment => "2^universe_size" create ComparativeContainmentI32CreateSpec, + ComparativeContainment => "2^universe_size" create ComparativeContainmentF64CreateSpec, } fn validate_set_family(label: &str, universe_size: usize, sets: &[Vec]) { diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index c65dc0c67..a4aab2288 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -4,7 +4,7 @@ //! subsets of X, determine if C contains an exact cover -- a subcollection of //! q disjoint triples covering every element exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -17,10 +17,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine if a collection of 3-element subsets contains an exact cover", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of universe X (must be divisible by 3)" }, - FieldInfo { name: "subsets", type_name: "Vec<[usize; 3]>", description: "Collection C of 3-element subsets of X" }, - ], + fields: ExactCoverBy3SetsCreateSpec::FIELDS, } } @@ -61,6 +58,40 @@ pub struct ExactCoverBy3Sets { subsets: Vec<[usize; 3]>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ExactCoverBy3SetsCreateSpec { + universe_size: usize, + #[create(codec = "semicolon-separated")] + subsets: Vec<[usize; 3]>, +} + +impl TryFrom for ExactCoverBy3Sets { + type Error = String; + fn try_from(mut spec: ExactCoverBy3SetsCreateSpec) -> Result { + if !spec.universe_size.is_multiple_of(3) { + return Err("universe_size must be divisible by 3".into()); + } + for (index, subset) in spec.subsets.iter_mut().enumerate() { + if subset[0] == subset[1] || subset[0] == subset[2] || subset[1] == subset[2] { + return Err(format!("subset {index} contains duplicate elements")); + } + if let Some(&element) = subset + .iter() + .find(|&&element| element >= spec.universe_size) + { + return Err(format!( + "subset {index} contains out-of-range element {element}" + )); + } + subset.sort(); + } + Ok(Self { + universe_size: spec.universe_size, + subsets: spec.subsets, + }) + } +} + impl ExactCoverBy3Sets { /// Create a new X3C problem. /// @@ -207,7 +238,7 @@ impl Problem for ExactCoverBy3Sets { } crate::declare_variants! { - default ExactCoverBy3Sets => "2^universe_size", + default ExactCoverBy3Sets => "2^universe_size" create ExactCoverBy3SetsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index fb199d5a2..6dede9a31 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -3,7 +3,7 @@ //! The Set Packing problem asks for a maximum weight collection of //! pairwise disjoint sets. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; use num_traits::Zero; @@ -18,10 +18,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "One", &["One", "i32", "f64"])], module_path: module_path!(), description: "Find maximum weight collection of disjoint sets", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of sets over a universe" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MaximumSetPackingCreateSpec::::FIELDS, } } @@ -61,6 +58,29 @@ pub struct MaximumSetPacking { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumSetPackingCreateSpec { + /// Collection of sets over a universe. + subsets: Vec>, + /// Weight for each set. + weights: Vec, +} + +impl TryFrom> for MaximumSetPacking { + type Error = String; + + fn try_from(spec: MaximumSetPackingCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + Ok(Self::with_weights(spec.subsets, spec.weights)) + } +} + impl MaximumSetPacking { /// Create a new Set Packing problem with unit weights. pub fn new(sets: Vec>) -> Self @@ -166,9 +186,9 @@ where } crate::declare_variants! { - default MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", + default MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, } /// Check if a selection forms a valid set packing (pairwise disjoint). diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index e7b0d47d2..17fdb7b97 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -3,7 +3,7 @@ //! The Minimum Hitting Set problem asks for a minimum-size subset of universe //! elements that intersects every set in a collection. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -16,10 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Find a minimum-size subset of universe elements that hits every set", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U that must each be hit" }, - ], + fields: MinimumHittingSetCreateSpec::FIELDS, } } @@ -40,6 +37,30 @@ pub struct MinimumHittingSet { sets: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumHittingSetCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U that must each be hit. + subsets: Vec>, +} + +impl TryFrom for MinimumHittingSet { + type Error = String; + + fn try_from(spec: MinimumHittingSetCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets)) + } +} + impl MinimumHittingSet { /// Create a new Minimum Hitting Set instance. /// @@ -144,7 +165,7 @@ impl Problem for MinimumHittingSet { } crate::declare_variants! { - default MinimumHittingSet => "2^universe_size", + default MinimumHittingSet => "2^universe_size" create MinimumHittingSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 4387375a5..fb28fd1a5 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -3,7 +3,7 @@ //! The Set Covering problem asks for a minimum weight collection of sets //! that covers all elements in the universe. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; use num_traits::Zero; @@ -18,11 +18,7 @@ inventory::submit! { dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], module_path: module_path!(), description: "Find minimum weight collection covering the universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MinimumSetCoveringCreateSpec::FIELDS, } } @@ -68,6 +64,43 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSetCoveringCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U. + subsets: Vec>, + /// Weight for each subset. + weights: Vec, +} + +impl TryFrom for MinimumSetCovering { + type Error = String; + + fn try_from(spec: MinimumSetCoveringCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::with_weights( + spec.universe_size, + spec.subsets, + spec.weights, + )) + } +} + impl MinimumSetCovering { /// Create a new Set Covering problem with unit weights. pub fn new(universe_size: usize, sets: Vec>) -> Self @@ -171,7 +204,7 @@ where } crate::declare_variants! { - default MinimumSetCovering => "2^num_sets", + default MinimumSetCovering => "2^num_sets" create MinimumSetCoveringCreateSpec, } /// Check if a selection of sets forms a valid set cover. diff --git a/src/models/set/prime_attribute_name.rs b/src/models/set/prime_attribute_name.rs index 96a9741e1..d956424da 100644 --- a/src/models/set/prime_attribute_name.rs +++ b/src/models/set/prime_attribute_name.rs @@ -3,7 +3,7 @@ //! Given a set of attributes A, a collection of functional dependencies F on A, //! and a query attribute x, determine if x belongs to any candidate key of . -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,11 +15,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine if an attribute belongs to any candidate key under functional dependencies", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Number of attributes" }, - FieldInfo { name: "dependencies", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs, rhs) pairs" }, - FieldInfo { name: "query_attribute", type_name: "usize", description: "The query attribute index" }, - ], + fields: PrimeAttributeNameCreateSpec::FIELDS, } } @@ -70,6 +66,51 @@ pub struct PrimeAttributeName { query_attribute: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrimeAttributeNameCreateSpec { + /// Number of attributes. + universe_size: usize, + /// Functional dependencies (lhs, rhs) pairs. + dependencies: Vec<(Vec, Vec)>, + /// The query attribute index. + query_attribute: usize, +} + +impl TryFrom for PrimeAttributeName { + type Error = String; + + fn try_from(spec: PrimeAttributeNameCreateSpec) -> Result { + if spec.query_attribute >= spec.universe_size { + return Err(format!( + "query_attribute {} is outside universe of size {}", + spec.query_attribute, spec.universe_size + )); + } + for (dependency_index, (lhs, rhs)) in spec.dependencies.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "dependencies[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.universe_size) + { + return Err(format!( + "dependencies[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new( + spec.universe_size, + spec.dependencies, + spec.query_attribute, + )) + } +} + impl PrimeAttributeName { /// Create a new Prime Attribute Name problem. /// @@ -205,7 +246,7 @@ impl Problem for PrimeAttributeName { } crate::declare_variants! { - default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes", + default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes" create PrimeAttributeNameCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/set_basis.rs b/src/models/set/set_basis.rs index e1b9f92bf..b8fc22da4 100644 --- a/src/models/set/set_basis.rs +++ b/src/models/set/set_basis.rs @@ -4,7 +4,7 @@ //! determine whether there exist `k` basis sets such that every target set //! can be reconstructed as a union of some subcollection of the basis. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -16,11 +16,7 @@ inventory::submit! { dimensions: &[], module_path: module_path!(), description: "Determine whether a collection of sets admits a basis of size k under union", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the ground set S" }, - FieldInfo { name: "collection", type_name: "Vec>", description: "Collection C of target subsets of S" }, - FieldInfo { name: "k", type_name: "usize", description: "Required number of basis sets" }, - ], + fields: SetBasisCreateSpec::FIELDS, } } @@ -40,6 +36,32 @@ pub struct SetBasis { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SetBasisCreateSpec { + /// Size of the ground set S. + universe_size: usize, + /// Collection C of target subsets of S. + subsets: Vec>, + /// Required number of basis sets. + k: usize, +} + +impl TryFrom for SetBasis { + type Error = String; + + fn try_from(spec: SetBasisCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets, spec.k)) + } +} + impl SetBasis { /// Create a new Set Basis instance. /// @@ -171,7 +193,7 @@ impl Problem for SetBasis { } crate::declare_variants! { - default SetBasis => "2^(basis_size * universe_size)", + default SetBasis => "2^(basis_size * universe_size)" create SetBasisCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/registry/info.rs b/src/registry/info.rs index 919670a8e..d39ca69c7 100644 --- a/src/registry/info.rs +++ b/src/registry/info.rs @@ -125,7 +125,7 @@ pub struct ProblemInfo { pub canonical_reduction_from: Option<&'static str>, /// Wikipedia or reference URL. pub reference_url: Option<&'static str>, - /// Struct field descriptions for schema export. + /// Construction input descriptions for schema export. pub fields: &'static [FieldInfo], } @@ -181,7 +181,7 @@ impl ProblemInfo { self } - /// Builder method to set struct field descriptions. + /// Builder method to set construction input descriptions. pub const fn with_fields(mut self, fields: &'static [FieldInfo]) -> Self { self.fields = fields; self @@ -206,10 +206,10 @@ impl fmt::Display for ProblemInfo { } } -/// Description of a struct field for JSON schema export. +/// Description of a problem construction input for schema export. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldInfo { - /// Field name as it appears in the Rust struct. + /// Input name supplied when constructing the problem. pub name: &'static str, /// Type name (e.g., `Vec`, `UnGraph<(), ()>`). pub type_name: &'static str, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 99243a5fc..5e91ffa07 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -60,10 +60,27 @@ pub use schema::{ ProblemSizeFieldEntry, VariantDimension, }; pub use variant::{ - find_variant_by_alias, find_variant_entry, validate_variant_aliases, variant_entries, - VariantEntry, + find_variant_by_alias, find_variant_entry, validate_create_inputs, + validate_direct_create_inputs, validate_variant_aliases, variant_entries, ConstructProblemFn, + ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, VariantEntry, }; +/// Construct a problem from normalized construction inputs using the exact +/// registered problem name and variant. +pub fn construct_dyn( + name: &str, + variant: &BTreeMap, + data: serde_json::Value, +) -> Result, ConstructionError> { + let entry = find_variant_entry(name, variant).ok_or_else(|| { + ConstructionError::UnregisteredVariant { + name: name.to_string(), + variant: variant.clone(), + } + })?; + (entry.construct_fn)(data) +} + use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 5337873c2..20d86fbb5 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -17,7 +17,7 @@ pub struct ProblemType { pub dimensions: &'static [VariantDimension], /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], } diff --git a/src/registry/schema.rs b/src/registry/schema.rs index 3fd9dcecd..0cf5ce15d 100644 --- a/src/registry/schema.rs +++ b/src/registry/schema.rs @@ -46,7 +46,7 @@ pub struct ProblemSchemaEntry { pub module_path: &'static str, /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], } @@ -72,7 +72,7 @@ pub struct ProblemSchemaJson { pub name: String, /// Problem description. pub description: String, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: Vec, } diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 3e64f9140..ef5299e5f 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -4,6 +4,172 @@ use std::any::Any; use std::collections::BTreeMap; use crate::registry::dyn_problem::{DynProblem, SolveValueFn, SolveWitnessFn}; +use crate::registry::FieldInfo; + +/// Reusable syntax used to transport one construction input. +/// +/// `Auto` asks a frontend to choose the codec from `type_name`. The explicit +/// variants are for Rust types whose compact external syntax is ambiguous. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CreateInputCodec { + /// Infer the transport syntax from the Rust value type. + #[default] + Auto, + /// A single scalar value. + Scalar, + /// A JSON value. + Json, + /// Comma-separated values. + CommaSeparated, + /// Semicolon-separated rows or groups. + SemicolonSeparated, + /// Undirected edges such as `0-1,1-2`. + EdgeList, + /// Directed arcs such as `0>1,1>2`. + ArcList, + /// Bipartite-local edges such as `0-0,0-1`. + BipartiteEdgeList, + /// Equality-linked index pairs such as `2=5;4=3`. + EqualityPairList, + /// Functional dependencies such as `0,1:2;2:3,4`. + FunctionalDependencyList, + /// Semicolon-separated character strings sharing one inferred alphabet. + CharacterRows, +} + +/// A user-facing input accepted when constructing a problem instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CreateInputInfo { + /// Input name in snake_case. Frontends may render it in their native style. + pub name: &'static str, + /// Concrete Rust value type accepted by the construction spec. + pub type_name: &'static str, + /// Human-readable input description. + pub description: &'static str, + /// Whether the input must be present. + pub required: bool, + /// Reusable transport syntax for this input. + pub codec: CreateInputCodec, +} + +impl CreateInputInfo { + /// Promote catalog field metadata into a required construction input. + pub const fn from_field(field: FieldInfo) -> Self { + Self { + name: field.name, + type_name: field.type_name, + description: field.description, + required: true, + codec: CreateInputCodec::Auto, + } + } +} + +/// Static construction-input metadata generated from a typed create spec. +pub trait CreateSpec { + /// Construction-facing field metadata used by the problem catalog. + const FIELDS: &'static [FieldInfo]; + /// Inputs accepted by this construction spec. + const INPUTS: &'static [CreateInputInfo]; + + /// Deserialize normalized construction inputs into the typed specification. + fn deserialize_inputs(data: serde_json::Value) -> Result + where + Self: Sized + serde::de::DeserializeOwned, + { + serde_json::from_value(data) + } +} + +/// Failure while validating or applying a model construction contract. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConstructionError { + /// No concrete variant matches the requested problem reference. + #[error("no registered variant for `{name}` with variant {variant:?}")] + UnregisteredVariant { + /// Canonical problem name. + name: String, + /// Exact requested variant. + variant: BTreeMap, + }, + /// Construction values must be supplied as a named JSON object. + #[error("construction inputs must be a JSON object")] + ExpectedObject, + /// A construction contract declared the same input more than once. + #[error("construction input `{0}` is declared more than once")] + DuplicateInput(String), + /// The caller supplied values outside the declared construction contract. + #[error("unknown construction input(s): {}", .0.join(", "))] + UnknownInputs(Vec), + /// The caller omitted required construction values. + #[error("missing required construction input(s): {}", .0.join(", "))] + MissingInputs(Vec), + /// Normalized values could not be deserialized into the direct model or create spec. + #[error("invalid construction input: {0}")] + InvalidInput(String), + /// A typed create spec failed to convert into the problem model. + #[error("problem construction failed: {0}")] + Conversion(String), +} + +/// Type-erased problem constructor used by dynamic frontends. +pub type ConstructProblemFn = + fn(serde_json::Value) -> Result, ConstructionError>; + +/// Validate normalized values against a typed construction contract. +pub fn validate_create_inputs( + inputs: &[CreateInputInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract( + inputs.iter().map(|input| (input.name, input.required)), + data, + ) +} + +/// Validate the direct-construction path backed by catalog field metadata. +/// +/// Direct models have no separate create DTO, so every catalog field is a +/// required construction input. +pub fn validate_direct_create_inputs( + fields: &[FieldInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract(fields.iter().map(|field| (field.name, true)), data) +} + +fn validate_input_contract<'a>( + inputs: impl IntoIterator, + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + let object = data.as_object().ok_or(ConstructionError::ExpectedObject)?; + let mut declared = BTreeMap::new(); + for (name, required) in inputs { + if declared.insert(name, required).is_some() { + return Err(ConstructionError::DuplicateInput(name.to_string())); + } + } + + let unknown = object + .keys() + .filter(|name| !declared.contains_key(name.as_str())) + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(ConstructionError::UnknownInputs(unknown)); + } + + let missing = declared + .into_iter() + .filter(|(name, required)| *required && !object.contains_key(*name)) + .map(|(name, _)| name.to_string()) + .collect::>(); + if !missing.is_empty() { + return Err(ConstructionError::MissingInputs(missing)); + } + + Ok(()) +} /// A registered problem variant entry. /// @@ -28,6 +194,11 @@ pub struct VariantEntry { /// specific reduction-graph node, not just to a canonical problem name. The CLI /// resolver tries variant-level aliases first and falls back to problem-level. pub aliases: &'static [&'static str], + /// Custom construction inputs. `None` means the catalog schema fields are + /// also the construction inputs through the direct path. + pub create_inputs: Option<&'static [CreateInputInfo]>, + /// Construct a validated concrete problem from normalized construction data. + pub construct_fn: ConstructProblemFn, /// Factory: deserialize JSON into a boxed dynamic problem. pub factory: fn(serde_json::Value) -> Result, serde_json::Error>, /// Serialize: downcast `&dyn Any` and serialize to JSON. diff --git a/src/unit_tests/models/algebraic/closest_vector_problem.rs b/src/unit_tests/models/algebraic/closest_vector_problem.rs index ff5e41dc4..f776b7ca4 100644 --- a/src/unit_tests/models/algebraic/closest_vector_problem.rs +++ b/src/unit_tests/models/algebraic/closest_vector_problem.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_expands_shared_default_bounds() { + let problem = ClosestVectorProblem::::try_from(ClosestVectorProblemI32CreateSpec { + basis: vec![vec![1, 0], vec![0, 1]], + target: vec![0.5, 0.5], + bounds: None, + }) + .unwrap(); + assert_eq!(problem.bounds(), &[VarBounds::bounded(-10, 10); 2]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs index 7d66b6459..73b507f77 100644 --- a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs +++ b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs @@ -2,6 +2,20 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_consecutive_block_create_spec_uses_bound_k_input() { + assert_eq!( + ConsecutiveBlockMinimizationCreateSpec::FIELDS[1].name, + "bound_k" + ); + let problem = ConsecutiveBlockMinimization::try_from(ConsecutiveBlockMinimizationCreateSpec { + matrix: vec![vec![true, false]], + bound_k: 1, + }) + .unwrap(); + assert_eq!(problem.bound(), 1); +} + #[test] fn test_consecutive_block_minimization_basic() { let problem = ConsecutiveBlockMinimization::new( diff --git a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs index 16fc52b93..58b77c42d 100644 --- a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_rejects_negative_bound() { + assert_eq!( + ConsecutiveOnesMatrixAugmentationCreateSpec::FIELDS[1].name, + "bound" + ); + assert!(ConsecutiveOnesMatrixAugmentation::try_from( + ConsecutiveOnesMatrixAugmentationCreateSpec { + matrix: vec![vec![true]], + bound: -1 + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/algebraic/feasible_basis_extension.rs b/src/unit_tests/models/algebraic/feasible_basis_extension.rs index dc7136e51..dea8a49ab 100644 --- a/src/unit_tests/models/algebraic/feasible_basis_extension.rs +++ b/src/unit_tests/models/algebraic/feasible_basis_extension.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_validates_matrix_shape() { + let problem = FeasibleBasisExtension::try_from(FeasibleBasisExtensionCreateSpec { + matrix: vec![vec![1, 0]], + rhs: vec![1], + required_columns: vec![], + }) + .unwrap(); + assert_eq!(problem.num_columns(), 2); + assert!( + FeasibleBasisExtension::try_from(FeasibleBasisExtensionCreateSpec { + matrix: vec![vec![1], vec![1]], + rhs: vec![1, 1], + required_columns: vec![] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs index 573353399..98ecf3ead 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_maps_rhs_to_target() { + let problem = MinimumWeightDecoding::try_from(MinimumWeightDecodingCreateSpec { + matrix: vec![vec![true, false]], + target: vec![true], + }) + .unwrap(); + assert_eq!(problem.target(), &[true]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs index 4c6b2b30d..8c6b30d61 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_rhs_length_mismatch() { + assert!( + MinimumWeightSolutionToLinearEquations::try_from(MinimumWeightSolutionCreateSpec { + matrix: vec![vec![1, 2]], + rhs: vec![] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 83ebae164..7332b5034 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -118,3 +118,15 @@ fn test_qubo_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(Problem::evaluate(&problem, &best), Min(Some(-2.0))); } + +#[test] +fn test_qubo_create_spec_derives_num_vars() { + let problem = QUBO::try_from(QuboCreateSpec { + matrix: vec![vec![1.0, 2.0], vec![0.0, 3.0]], + }) + .unwrap(); + + assert_eq!(problem.num_vars(), 2); + assert_eq!(QuboCreateSpec::FIELDS[0].name, "matrix"); + assert_eq!(QuboCreateSpec::FIELDS.len(), 1); +} diff --git a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs index 2d3b48630..e9c42055f 100644 --- a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs +++ b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_zero_bound() { + assert_eq!(SparseMatrixCompressionCreateSpec::FIELDS[1].name, "bound_k"); + let result = SparseMatrixCompression::try_from(SparseMatrixCompressionCreateSpec { + matrix: vec![vec![true]], + bound_k: 0, + }); + assert!(result.is_err()); +} use crate::registry::VariantEntry; use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index e78a2d19b..78c604e38 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -76,6 +76,50 @@ fn test_decision_serialization() { assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); } +#[test] +fn construction_contract_decision_uses_flat_inner_fields() { + let inner = triangle_mvc(); + let mut flat = serde_json::to_value(&inner) + .unwrap() + .as_object() + .unwrap() + .clone(); + flat.insert("bound".to_string(), serde_json::json!(2)); + let variant = crate::export::variant_to_map( + > as Problem>::variant(), + ); + + let constructed = crate::registry::construct_dyn( + "DecisionMinimumVertexCover", + &variant, + serde_json::Value::Object(flat), + ) + .unwrap(); + let canonical = constructed.serialize_json(); + + assert!(canonical.get("inner").is_some()); + assert_eq!(canonical["bound"], serde_json::json!(2)); + assert_eq!(canonical["inner"]["weights"], serde_json::json!([1, 1, 1])); +} + +#[test] +fn construction_contract_decision_rejects_nested_persisted_shape() { + let variant = crate::export::variant_to_map( + > as Problem>::variant(), + ); + let error = crate::registry::construct_dyn( + "DecisionMinimumVertexCover", + &variant, + serde_json::json!({"inner": triangle_mvc(), "bound": 2}), + ) + .err() + .expect("nested persisted shape must not be accepted for construction"); + + assert!(error + .to_string() + .contains("unknown construction input(s): inner")); +} + #[test] fn test_decision_reduce_to_aggregate() { use crate::rules::{AggregateReductionResult, ReduceToAggregate}; diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index 70e1d7df8..bebd5c874 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -215,3 +215,31 @@ fn test_acyclic_partition_declares_problem_size_fields() { .collect(); assert_eq!(fields, HashSet::from(["num_vertices", "num_arcs"])); } +#[test] +fn create_spec_maps_weight_inputs_to_canonical_fields() { + let problem = AcyclicPartition::try_from(AcyclicPartitionCreateSpec { + arcs: vec![(0, 1)], + num_vertices: Some(3), + weights: None, + arc_weights: Some(vec![2]), + weight_bound: 3, + cost_bound: 2, + }) + .unwrap(); + assert_eq!(problem.vertex_weights(), &[1, 1, 1]); + assert_eq!(problem.arc_costs(), &[2]); + assert_eq!( + AcyclicPartitionCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + [ + "arcs", + "num_vertices", + "weights", + "arc_costs", + "weight_bound", + "cost_bound" + ] + ); +} diff --git a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs index 2faab061f..f9a13a9f7 100644 --- a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,4 +1,27 @@ use super::*; + +#[test] +fn create_spec_builds_bipartite_graph_and_rejects_invalid_edges() { + let problem = + BalancedCompleteBipartiteSubgraph::try_from(BalancedCompleteBipartiteSubgraphCreateSpec { + left: 2, + right: 2, + biedges: vec![(0, 1), (1, 0)], + k: 1, + }) + .unwrap(); + assert_eq!(problem.graph().left_edges(), &[(0, 1), (1, 0)]); + assert_eq!(problem.k(), 1); + assert!(BalancedCompleteBipartiteSubgraph::try_from( + BalancedCompleteBipartiteSubgraphCreateSpec { + left: 1, + right: 1, + biedges: vec![(1, 0)], + k: 1, + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::BipartiteGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/biclique_cover.rs b/src/unit_tests/models/graph/biclique_cover.rs index 6693e4fa2..30a60e1a6 100644 --- a/src/unit_tests/models/graph/biclique_cover.rs +++ b/src/unit_tests/models/graph/biclique_cover.rs @@ -4,6 +4,77 @@ use crate::topology::BipartiteGraph; use crate::traits::Problem; use crate::types::Min; +#[test] +fn test_biclique_cover_create_spec_constructs_graph() { + let problem = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 2, + right: 3, + biedges: vec![(0, 0), (0, 2), (1, 1)], + k: 2, + }) + .unwrap(); + + assert_eq!(problem.left_size(), 2); + assert_eq!(problem.right_size(), 3); + assert_eq!(problem.graph().left_edges(), &[(0, 0), (0, 2), (1, 1)]); + assert_eq!(problem.k(), 2); + + let entry = inventory::iter::() + .find(|entry| entry.name == "BicliqueCover") + .unwrap(); + let inputs = entry.create_inputs.unwrap(); + assert_eq!( + inputs.iter().map(|input| input.name).collect::>(), + vec!["left", "right", "biedges", "k"] + ); + assert_eq!( + inputs[2].codec, + crate::registry::CreateInputCodec::BipartiteEdgeList + ); + + let constructed = (entry.construct_fn)(serde_json::json!({ + "left": 2, + "right": 3, + "biedges": [[0, 0], [0, 2], [1, 1]], + "k": 2 + })) + .unwrap(); + let constructed = constructed + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + constructed.graph().left_edges(), + problem.graph().left_edges() + ); + assert_eq!(constructed.k(), problem.k()); +} + +#[test] +fn test_biclique_cover_create_spec_rejects_out_of_bounds_edges() { + let invalid_left = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 1, + right: 2, + biedges: vec![(1, 0)], + k: 1, + }); + assert_eq!( + invalid_left.unwrap_err(), + "biedges[0] left vertex 1 is out of bounds for left partition size 1" + ); + + let invalid_right = BicliqueCover::try_from(BicliqueCoverCreateSpec { + left: 2, + right: 1, + biedges: vec![(0, 1)], + k: 1, + }); + assert_eq!( + invalid_right.unwrap_err(), + "biedges[0] right vertex 1 is out of bounds for right partition size 1" + ); +} + #[test] fn test_biclique_cover_creation() { let graph = BipartiteGraph::new(2, 2, vec![(0, 0), (0, 1), (1, 0)]); diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index db4f33fc7..a821ead2d 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -1,4 +1,16 @@ use super::*; +#[test] +fn create_spec_rejects_existing_potential_edge() { + assert!( + BiconnectivityAugmentation::try_from(BiconnectivityAugmentationCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + potential_weights: vec![(0, 1, 2)], + budget: 3 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs index e8f72c0df..4b317807c 100644 --- a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs +++ b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs @@ -120,3 +120,17 @@ fn test_bottleneck_traveling_salesman_paper_example() { assert_eq!(best.len(), 1); assert_eq!(best[0], config); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = BottleneckTravelingSalesman::try_from(BottleneckTravelingSalesmanCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1]); + assert_eq!( + BottleneckTravelingSalesmanCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs index 5e2573001..87a810d30 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -6,6 +6,25 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; use std::sync::atomic::{AtomicUsize, Ordering}; +#[test] +fn create_spec_uses_k_and_max_weight_inputs() { + let names: Vec<_> = BoundedComponentSpanningForestCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect(); + assert_eq!(names, ["graph", "weights", "k", "max_weight"]); + let problem = + BoundedComponentSpanningForest::try_from(BoundedComponentSpanningForestCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1, 2], + k: 1, + max_weight: 3, + }) + .unwrap(); + assert_eq!(problem.max_components(), 1); + assert_eq!(problem.max_weight(), &3); +} + struct CountingAllocator; static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0); diff --git a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs index d4325e603..58f830222 100644 --- a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs @@ -132,3 +132,19 @@ fn test_bounded_diameter_spanning_tree_wrong_weights_length_panics() { let _ = BoundedDiameterSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1], 5, 2); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = BoundedDiameterSpanningTree::try_from(BoundedDiameterSpanningTreeCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + weight_bound: 1, + diameter_bound: 1, + }) + .unwrap(); + assert_eq!(problem.edge_weights(), &[1]); + assert_eq!( + BoundedDiameterSpanningTreeCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/disjoint_connecting_paths.rs b/src/unit_tests/models/graph/disjoint_connecting_paths.rs index 6c613bd07..e4be74a02 100644 --- a/src/unit_tests/models/graph/disjoint_connecting_paths.rs +++ b/src/unit_tests/models/graph/disjoint_connecting_paths.rs @@ -1,4 +1,15 @@ use super::*; +#[test] +fn create_spec_rejects_reused_terminal() { + assert!( + DisjointConnectingPaths::try_from(DisjointConnectingPathsCreateSpec { + graph: vec![(0, 1), (1, 2)], + num_vertices: None, + terminal_pairs: vec![(0, 1), (1, 2)] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/generalized_hex.rs b/src/unit_tests/models/graph/generalized_hex.rs index 7b9799099..57ebded1b 100644 --- a/src/unit_tests/models/graph/generalized_hex.rs +++ b/src/unit_tests/models/graph/generalized_hex.rs @@ -3,6 +3,18 @@ use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; +#[test] +fn create_spec_uses_sink_input() { + assert_eq!(GeneralizedHexCreateSpec::FIELDS[2].name, "sink"); + let problem = GeneralizedHex::try_from(GeneralizedHexCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + source: 0, + sink: 1, + }) + .unwrap(); + assert_eq!(problem.target(), 1); +} + fn issue_example() -> GeneralizedHex { GeneralizedHex::new( SimpleGraph::new( diff --git a/src/unit_tests/models/graph/integral_flow_bundles.rs b/src/unit_tests/models/graph/integral_flow_bundles.rs index 0e95b3c24..d55cb1e60 100644 --- a/src/unit_tests/models/graph/integral_flow_bundles.rs +++ b/src/unit_tests/models/graph/integral_flow_bundles.rs @@ -1,4 +1,19 @@ use super::*; +#[test] +fn create_spec_requires_bundle_coverage() { + assert!( + IntegralFlowBundles::try_from(IntegralFlowBundlesCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + bundles: vec![vec![0]], + bundle_capacities: vec![1], + source: 0, + sink: 2, + requirement: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs index 2ce6a5e7d..4900cce87 100644 --- a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs +++ b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs @@ -1,4 +1,18 @@ use super::*; +#[test] +fn create_spec_defaults_capacities() { + let problem = IntegralFlowHomologousArcs::try_from(IntegralFlowHomologousArcsCreateSpec { + arcs: vec![(0, 1)], + num_vertices: None, + capacities: None, + source: 0, + sink: 1, + requirement: 1, + homologous_pairs: vec![], + }) + .unwrap(); + assert_eq!(problem.capacities(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs index a4afd16af..865160196 100644 --- a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs +++ b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs @@ -1,4 +1,19 @@ use super::*; +#[test] +fn create_spec_rejects_zero_internal_multiplier() { + assert!( + IntegralFlowWithMultipliers::try_from(IntegralFlowWithMultipliersCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + capacities: vec![1, 1], + source: 0, + sink: 2, + multipliers: vec![1, 0, 1], + requirement: 1 + }) + .is_err() + ); +} use crate::registry::declared_size_fields; use crate::solvers::BruteForce; use crate::topology::DirectedGraph; diff --git a/src/unit_tests/models/graph/kclique.rs b/src/unit_tests/models/graph/kclique.rs index 16aca9e99..cd8ae19d1 100644 --- a/src/unit_tests/models/graph/kclique.rs +++ b/src/unit_tests/models/graph/kclique.rs @@ -1,4 +1,13 @@ use super::*; +#[test] +fn create_spec_rejects_k_above_vertex_count() { + assert!(KClique::try_from(KCliqueCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + k: 3 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 2185b0337..f2e9618ed 100644 --- a/src/unit_tests/models/graph/kcoloring.rs +++ b/src/unit_tests/models/graph/kcoloring.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_specs_separate_runtime_and_fixed_color_counts() { + let runtime = KColoring::::try_from(RuntimeKColoringCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + k: 4, + }) + .unwrap(); + assert_eq!(runtime.num_vertices(), 3); + assert_eq!(runtime.num_colors(), 4); + + let fixed = KColoring::::try_from(FixedKColoringCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + }) + .unwrap(); + assert_eq!(fixed.num_colors(), 3); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::variant::{K1, K2, K3, K4}; diff --git a/src/unit_tests/models/graph/kth_best_spanning_tree.rs b/src/unit_tests/models/graph/kth_best_spanning_tree.rs index 1c3464260..8a29441b2 100644 --- a/src/unit_tests/models/graph/kth_best_spanning_tree.rs +++ b/src/unit_tests/models/graph/kth_best_spanning_tree.rs @@ -154,3 +154,19 @@ fn test_kthbestspanningtree_creation_rejects_weight_length_mismatch() { fn test_kthbestspanningtree_creation_rejects_zero_k() { let _ = KthBestSpanningTree::::new(SimpleGraph::new(1, vec![]), vec![], 0, 0); } +#[test] +fn create_spec_maps_edge_weights_to_weights() { + let problem = KthBestSpanningTree::try_from(KthBestSpanningTreeCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + k: 1, + bound: 2, + }) + .unwrap(); + assert_eq!(problem.weights(), &[1]); + assert_eq!( + KthBestSpanningTreeCreateSpec::FIELDS[2].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index 53a19ed4f..9f9b3054c 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_derives_path_slot_bound() { + let problem = LengthBoundedDisjointPaths::try_from(LengthBoundedDisjointPathsCreateSpec { + graph: vec![(0, 1), (1, 3), (0, 2), (2, 3)], + num_vertices: None, + source: 0, + sink: 3, + max_length: 2, + }) + .unwrap(); + assert_eq!(problem.max_paths(), 2); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/longest_circuit.rs b/src/unit_tests/models/graph/longest_circuit.rs index e11c1258e..acd6d871b 100644 --- a/src/unit_tests/models/graph/longest_circuit.rs +++ b/src/unit_tests/models/graph/longest_circuit.rs @@ -116,3 +116,14 @@ fn test_longest_circuit_set_lengths_rejects_non_positive_values() { ); problem.set_lengths(vec![1, -2, 1]); } +#[test] +fn create_spec_maps_edge_weights_to_edge_lengths() { + let problem = LongestCircuit::try_from(LongestCircuitCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: Some(vec![3]), + }) + .unwrap(); + assert_eq!(problem.edge_lengths(), &[3]); + assert_eq!(LongestCircuitCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/longest_path.rs b/src/unit_tests/models/graph/longest_path.rs index 9b61616fe..7e7ff5aa4 100644 --- a/src/unit_tests/models/graph/longest_path.rs +++ b/src/unit_tests/models/graph/longest_path.rs @@ -1,4 +1,15 @@ use super::*; +#[test] +fn create_spec_rejects_nonpositive_lengths() { + assert!(LongestPath::try_from(LongestPathI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_lengths: vec![0], + source_vertex: 0, + target_vertex: 1 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/max_cut.rs b/src/unit_tests/models/graph/max_cut.rs index b75ce5bdf..1f4aef639 100644 --- a/src/unit_tests/models/graph/max_cut.rs +++ b/src/unit_tests/models/graph/max_cut.rs @@ -154,3 +154,21 @@ fn test_maxcut_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best).unwrap(), 5); } +#[test] +fn create_specs_use_edge_weights_for_both_weight_variants() { + let weighted = MaxCut::try_from(MaxCutI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + let unit = MaxCut::try_from(MaxCutOneCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(weighted.edge_weights(), vec![1]); + assert_eq!(unit.edge_weights(), vec![One]); + assert_eq!(MaxCutI32CreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/maximal_is.rs b/src/unit_tests/models/graph/maximal_is.rs index d0f1a1f1c..4e1616328 100644 --- a/src/unit_tests/models/graph/maximal_is.rs +++ b/src/unit_tests/models/graph/maximal_is.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!(MaximalISCreateSpec::FIELDS[1].name, "weights"); + let result = MaximalIS::try_from(MaximalISCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; include!("../../jl_helpers.rs"); diff --git a/src/unit_tests/models/graph/maximum_clique.rs b/src/unit_tests/models/graph/maximum_clique.rs index a91da8e33..c6d2df42a 100644 --- a/src/unit_tests/models/graph/maximum_clique.rs +++ b/src/unit_tests/models/graph/maximum_clique.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!(MaximumCliqueCreateSpec::::FIELDS[1].name, "weights"); + let result = MaximumClique::try_from(MaximumCliqueCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::types::{Max, One}; diff --git a/src/unit_tests/models/graph/maximum_co_k_plex.rs b/src/unit_tests/models/graph/maximum_co_k_plex.rs index 0630c5733..89510fd33 100644 --- a/src/unit_tests/models/graph/maximum_co_k_plex.rs +++ b/src/unit_tests/models/graph/maximum_co_k_plex.rs @@ -6,6 +6,19 @@ use crate::types::{Max, One}; use crate::variant::KN; use crate::Solver; +#[test] +fn create_spec_uses_k_input() { + assert_eq!(MaximumCoKPlexCreateSpec::::FIELDS[2].name, "k"); + let problem = MaximumCoKPlex::try_from(MaximumCoKPlexCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![2, 3], + k: 1, + }) + .unwrap(); + assert_eq!(problem.bound_k(), 1); + assert_eq!(problem.weights(), &[2, 3]); +} + fn c5() -> SimpleGraph { SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]) } diff --git a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs index 9762cfa0c..6d0452996 100644 --- a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MaximumEdgeWeightedKClique::try_from(MaximumEdgeWeightedKCliqueCreateSpec:: { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: None, + k: 2, + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/maximum_independent_set.rs b/src/unit_tests/models/graph/maximum_independent_set.rs index 9053054c9..0b37e0c25 100644 --- a/src/unit_tests/models/graph/maximum_independent_set.rs +++ b/src/unit_tests/models/graph/maximum_independent_set.rs @@ -1,4 +1,14 @@ use super::*; +#[test] +fn create_spec_defaults_simple_weights() { + let problem = MaximumIndependentSet::try_from(MaximumIndependentSetSimpleI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), &[1, 1, 1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/maximum_matching.rs b/src/unit_tests/models/graph/maximum_matching.rs index d01e67dfc..b3932223d 100644 --- a/src/unit_tests/models/graph/maximum_matching.rs +++ b/src/unit_tests/models/graph/maximum_matching.rs @@ -187,3 +187,14 @@ fn test_matching_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best).unwrap(), 2); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = MaximumMatching::try_from(MaximumMatchingCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1]); + assert_eq!(MaximumMatchingCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/min_max_multicenter.rs b/src/unit_tests/models/graph/min_max_multicenter.rs index c50d605d2..f0d8616b5 100644 --- a/src/unit_tests/models/graph/min_max_multicenter.rs +++ b/src/unit_tests/models/graph/min_max_multicenter.rs @@ -202,3 +202,30 @@ fn test_minmaxmulticenter_negative_edge_length() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); MinMaxMulticenter::new(graph, vec![1i32; 3], vec![1i32, -1], 1); } +#[test] +fn create_specs_map_weight_inputs_for_both_variants() { + let weighted = MinMaxMulticenter::try_from(MinMaxMulticenterI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: None, + edge_weights: Some(vec![2]), + k: 1, + }) + .unwrap(); + let unit = MinMaxMulticenter::try_from(MinMaxMulticenterOneCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: None, + edge_weights: None, + k: 1, + }) + .unwrap(); + assert_eq!(weighted.vertex_weights(), &[1, 1]); + assert_eq!(weighted.edge_lengths(), &[2]); + assert_eq!(unit.vertex_weights(), &[One, One]); + assert_eq!(MinMaxMulticenterI32CreateSpec::FIELDS[2].name, "weights"); + assert_eq!( + MinMaxMulticenterI32CreateSpec::FIELDS[3].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs index 5c74e8626..367c31d80 100644 --- a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MinimumCapacitatedSpanningTree::try_from(MinimumCapacitatedSpanningTreeCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: None, + root: 0, + requirements: vec![0, 1], + capacity: 1, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; /// 5-vertex instance from issue #901. diff --git a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs index fe40f07b3..87d16b852 100644 --- a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = MinimumCutIntoBoundedSets::try_from(MinimumCutIntoBoundedSetsCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: None, + source: 0, + sink: 1, + size_bound: 1, + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_dominating_set.rs b/src/unit_tests/models/graph/minimum_dominating_set.rs index b80eaedb5..5336b95b2 100644 --- a/src/unit_tests/models/graph/minimum_dominating_set.rs +++ b/src/unit_tests/models/graph/minimum_dominating_set.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!( + MinimumDominatingSetCreateSpec::::FIELDS[1].name, + "weights" + ); + let result = MinimumDominatingSet::try_from(MinimumDominatingSetCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs index c402935ac..b08f47b4d 100644 --- a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs +++ b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_rejects_cycle() { + assert_eq!(MinimumDummyActivitiesPertCreateSpec::FIELDS[0].name, "arcs"); + assert!( + MinimumDummyActivitiesPert::try_from(MinimumDummyActivitiesPertCreateSpec { + arcs: vec![(0, 1), (1, 0)], + num_vertices: Some(2), + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs index 1ede2865b..5627bfe0f 100644 --- a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -1,4 +1,14 @@ use super::*; + +#[test] +fn create_spec_defaults_arc_weights() { + let p = MinimumFeedbackArcSet::try_from(MinimumFeedbackArcSetCreateSpec { + graph: DirectedGraph::new(2, vec![(0, 1)]), + weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs index 00fd26274..c3fa3ff02 100644 --- a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -1,4 +1,14 @@ -use super::is_feedback_vertex_set; +use super::*; + +#[test] +fn create_spec_defaults_vertex_weights() { + let p = MinimumFeedbackVertexSet::try_from(MinimumFeedbackVertexSetCreateSpec { + graph: DirectedGraph::new(2, vec![(0, 1)]), + weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::models::graph::MinimumFeedbackVertexSet; use crate::solvers::BruteForce; use crate::topology::DirectedGraph; diff --git a/src/unit_tests/models/graph/minimum_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index 9cbbe5511..9f6f6a18b 100644 --- a/src/unit_tests/models/graph/minimum_multiway_cut.rs +++ b/src/unit_tests/models/graph/minimum_multiway_cut.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_invalid_terminals() { + assert_eq!(MinimumMultiwayCutCreateSpec::FIELDS[1].name, "terminals"); + let result = MinimumMultiwayCut::try_from(MinimumMultiwayCutCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + terminals: vec![0, 0], + edge_weights: vec![1], + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs index fdf833111..5c1882af5 100644 --- a/src/unit_tests/models/graph/minimum_sum_multicenter.rs +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -263,3 +263,21 @@ fn test_min_sum_multicenter_serialization() { deserialized.evaluate(&config).unwrap() ); } +#[test] +fn create_spec_maps_weight_inputs_to_canonical_fields() { + let problem = MinimumSumMulticenter::try_from(MinimumSumMulticenterCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + weights: Some(vec![2, 3]), + edge_weights: None, + k: 1, + }) + .unwrap(); + assert_eq!(problem.vertex_weights(), &[2, 3]); + assert_eq!(problem.edge_lengths(), &[1]); + assert_eq!(MinimumSumMulticenterCreateSpec::FIELDS[2].name, "weights"); + assert_eq!( + MinimumSumMulticenterCreateSpec::FIELDS[3].name, + "edge_weights" + ); +} diff --git a/src/unit_tests/models/graph/minimum_vertex_cover.rs b/src/unit_tests/models/graph/minimum_vertex_cover.rs index 2fb7b22a1..6b4dd506e 100644 --- a/src/unit_tests/models/graph/minimum_vertex_cover.rs +++ b/src/unit_tests/models/graph/minimum_vertex_cover.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_rejects_weight_count_mismatch() { + assert_eq!( + MinimumVertexCoverCreateSpec::::FIELDS[1].name, + "weights" + ); + let result = MinimumVertexCover::try_from(MinimumVertexCoverCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + weights: Some(vec![1]), + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs index a0ca382b3..721220dc9 100644 --- a/src/unit_tests/models/graph/mixed_chinese_postman.rs +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_infers_graph_and_default_weights() { + let problem = MixedChinesePostman::::try_from(MixedChinesePostmanI32CreateSpec { + graph: vec![(0, 1)], + arcs: vec![(1, 0)], + num_vertices: None, + arc_weights: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 2); + assert_eq!(problem.arc_weights(), &[1]); + assert_eq!(problem.edge_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::MixedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/multiple_choice_branching.rs b/src/unit_tests/models/graph/multiple_choice_branching.rs index 80ceb6750..aa05379e8 100644 --- a/src/unit_tests/models/graph/multiple_choice_branching.rs +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_invalid_partition() { + assert_eq!( + MultipleChoiceBranchingCreateSpec::FIELDS[3].name, + "partition" + ); + let result = MultipleChoiceBranching::try_from(MultipleChoiceBranchingCreateSpec { + arcs: vec![(0, 1)], + num_vertices: Some(2), + weights: vec![1], + partition: vec![], + threshold: 1, + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs index 17f0cad62..abfc1683d 100644 --- a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs +++ b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_preserves_isolated_vertices() { + let problem = MultipleCopyFileAllocation::try_from(MultipleCopyFileAllocationCreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + usage: vec![1, 1, 1], + storage: vec![2, 2, 2], + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 3); +} use crate::solvers::{BruteForce, Solver}; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/partial_feedback_edge_set.rs b/src/unit_tests/models/graph/partial_feedback_edge_set.rs index 2f6974355..4fef7554c 100644 --- a/src/unit_tests/models/graph/partial_feedback_edge_set.rs +++ b/src/unit_tests/models/graph/partial_feedback_edge_set.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_constructs_model() { + assert_eq!( + PartialFeedbackEdgeSetCreateSpec::FIELDS[2].name, + "max_cycle_length" + ); + let problem = PartialFeedbackEdgeSet::try_from(PartialFeedbackEdgeSetCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + budget: 1, + max_cycle_length: 3, + }) + .unwrap(); + assert_eq!(problem.budget(), 1); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/path_constrained_network_flow.rs b/src/unit_tests/models/graph/path_constrained_network_flow.rs index 751cde06e..9aef88f72 100644 --- a/src/unit_tests/models/graph/path_constrained_network_flow.rs +++ b/src/unit_tests/models/graph/path_constrained_network_flow.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_defaults_capacities_and_validates_paths() { + let problem = PathConstrainedNetworkFlow::try_from(PathConstrainedNetworkFlowCreateSpec { + arcs: vec![(0, 1), (1, 2)], + num_vertices: None, + capacities: None, + source: 0, + sink: 2, + paths: vec![vec![0, 1]], + requirement: 1, + }) + .unwrap(); + assert_eq!(problem.capacities(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::topology::DirectedGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs index d7efc585c..12e626795 100644 --- a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs +++ b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs @@ -171,3 +171,32 @@ fn test_prize_collecting_steiner_forest_rejects_edge_costs_length_mismatch() { 2, ); } +#[test] +fn create_specs_default_prizes_and_costs_to_one() { + let weighted = + PrizeCollectingSteinerForest::try_from(PrizeCollectingSteinerForestI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + vertex_prizes: None, + edge_costs: None, + beta: 2, + omega: 3, + }) + .unwrap(); + let floating = + PrizeCollectingSteinerForest::try_from(PrizeCollectingSteinerForestF64CreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + vertex_prizes: None, + edge_costs: None, + beta: 2.0, + omega: 3.0, + }) + .unwrap(); + assert_eq!(weighted.vertex_prizes(), &[1, 1, 1]); + assert_eq!(weighted.edge_costs(), &[1]); + assert_eq!(floating.vertex_prizes(), &[1.0, 1.0]); + assert_eq!(floating.edge_costs(), &[1.0]); + assert!(!PrizeCollectingSteinerForestI32CreateSpec::INPUTS[2].required); + assert!(!PrizeCollectingSteinerForestI32CreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs index 341fbdef5..ab598c751 100644 --- a/src/unit_tests/models/graph/rural_postman.rs +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -201,3 +201,15 @@ fn test_rural_postman_solver_aggregate() { let value = solver.solve(&problem); assert_eq!(value, Min(Some(4))); } +#[test] +fn create_spec_maps_edge_weights_to_edge_lengths() { + let problem = RuralPostman::try_from(RuralPostmanCreateSpec { + graph: vec![(0, 1), (1, 2)], + num_vertices: None, + edge_weights: Some(vec![2, 3]), + required_edges: vec![1], + }) + .unwrap(); + assert_eq!(problem.edge_lengths(), &[2, 3]); + assert_eq!(RuralPostmanCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs index 1f845e1bc..c9341c6ff 100644 --- a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs +++ b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_nonpositive_edge_values() { + assert_eq!( + ShortestWeightConstrainedPathCreateSpec::FIELDS[1].name, + "edge_lengths" + ); + let result = ShortestWeightConstrainedPath::try_from(ShortestWeightConstrainedPathCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_lengths: vec![0], + edge_weights: vec![1], + source_vertex: 0, + target_vertex: 1, + weight_bound: 1, + }); + assert!(result.is_err()); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/spin_glass.rs b/src/unit_tests/models/graph/spin_glass.rs index 82633c899..004a45dad 100644 --- a/src/unit_tests/models/graph/spin_glass.rs +++ b/src/unit_tests/models/graph/spin_glass.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_couplings_and_fields() { + let problem = SpinGlass::::try_from(SpinGlassI32CreateSpec { + graph: vec![(0, 1)], + num_vertices: Some(3), + couplings: None, + fields: None, + }) + .unwrap(); + assert_eq!(problem.couplings(), &[1]); + assert_eq!(problem.fields(), &[0, 0, 0]); +} use crate::solvers::BruteForce; use crate::traits::Problem; include!("../../jl_helpers.rs"); diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 00cd8d505..c51dec498 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_rejects_duplicate_terminals() { + assert_eq!(SteinerTreeCreateSpec::::FIELDS[2].name, "terminals"); + let result = SteinerTree::try_from(SteinerTreeCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + edge_weights: vec![1], + terminals: vec![0, 0], + }); + assert!(result.is_err()); +} use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; /// Issue #122 example: 5 vertices, 7 edges, terminals {0, 2, 4}. diff --git a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs index cf09155cc..34f98928a 100644 --- a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs +++ b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = SteinerTreeInGraphs::try_from(SteinerTreeInGraphsCreateSpec:: { + graph: SimpleGraph::new(2, vec![(0, 1)]), + terminals: vec![0, 1], + edge_weights: None, + }) + .unwrap(); + assert_eq!(p.weights(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/traveling_salesman.rs b/src/unit_tests/models/graph/traveling_salesman.rs index 1dc55c774..a16417be8 100644 --- a/src/unit_tests/models/graph/traveling_salesman.rs +++ b/src/unit_tests/models/graph/traveling_salesman.rs @@ -255,3 +255,14 @@ fn test_tsp_paper_example() { let best = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&best), Min(Some(6))); } +#[test] +fn create_spec_uses_edge_weights_and_defaults_to_one() { + let problem = TravelingSalesman::try_from(TravelingSalesmanCreateSpec { + graph: vec![(0, 1), (1, 2), (2, 0)], + num_vertices: None, + edge_weights: None, + }) + .unwrap(); + assert_eq!(problem.weights(), vec![1, 1, 1]); + assert_eq!(TravelingSalesmanCreateSpec::FIELDS[2].name, "edge_weights"); +} diff --git a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs index ab54df324..a5e68dad2 100644 --- a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs +++ b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_rejects_lower_bound_above_capacity() { + assert_eq!( + UndirectedFlowLowerBoundsCreateSpec::FIELDS[2].name, + "lower_bounds" + ); + assert!( + UndirectedFlowLowerBounds::try_from(UndirectedFlowLowerBoundsCreateSpec { + graph: SimpleGraph::new(2, vec![(0, 1)]), + capacities: vec![1], + lower_bounds: vec![2], + source: 0, + sink: 1, + requirement: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs index c34f21bc9..3ff4dcd0a 100644 --- a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn create_spec_validates_capacity_shape() { + let problem = UndirectedTwoCommodityIntegralFlow::try_from( + UndirectedTwoCommodityIntegralFlowCreateSpec { + graph: vec![(0, 1)], + num_vertices: None, + capacities: vec![1], + source_1: 0, + sink_1: 1, + source_2: 1, + sink_2: 0, + requirement_1: 1, + requirement_2: 1, + }, + ) + .unwrap(); + assert_eq!(problem.capacities(), &[1]); +} use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs index d036f415e..325e67dc0 100644 --- a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs @@ -15,6 +15,22 @@ fn canonical_problem() -> BoyceCoddNormalFormViolation { ) } +#[test] +fn test_bcnf_create_spec_uses_construction_names() { + let names: Vec<_> = BoyceCoddNormalFormViolationCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect(); + assert_eq!(names, ["n", "subsets", "target"]); + let problem = BoyceCoddNormalFormViolation::try_from(BoyceCoddNormalFormViolationCreateSpec { + n: 3, + subsets: vec![(vec![0], vec![1])], + target: vec![0, 1, 2], + }) + .unwrap(); + assert_eq!(problem.num_attributes(), 3); +} + #[test] fn test_bcnf_creation() { let problem = canonical_problem(); diff --git a/src/unit_tests/models/misc/capacity_assignment.rs b/src/unit_tests/models/misc/capacity_assignment.rs index ffe135e3d..548fca7e0 100644 --- a/src/unit_tests/models/misc/capacity_assignment.rs +++ b/src/unit_tests/models/misc/capacity_assignment.rs @@ -1,4 +1,16 @@ +use super::CapacityAssignmentCreateSpec; use crate::models::misc::CapacityAssignment; + +#[test] +fn create_spec_validates_monotonicity() { + assert!(CapacityAssignment::try_from(CapacityAssignmentCreateSpec { + capacities: vec![1, 2], + cost: vec![vec![2, 1]], + delay: vec![vec![2, 1]], + delay_budget: 3 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/conjunctive_boolean_query.rs b/src/unit_tests/models/misc/conjunctive_boolean_query.rs index 3ca9e701e..95b558564 100644 --- a/src/unit_tests/models/misc/conjunctive_boolean_query.rs +++ b/src/unit_tests/models/misc/conjunctive_boolean_query.rs @@ -135,3 +135,36 @@ fn test_conjunctivebooleanquery_paper_example() { assert_eq!(all.len(), 1); assert_eq!(all[0], vec![0, 1]); } + +#[test] +fn test_conjunctivebooleanquery_create_spec_derives_variables() { + let problem = ConjunctiveBooleanQuery::try_from(ConjunctiveBooleanQueryCreateSpec { + domain_size: 3, + relations: vec![Relation { + arity: 2, + tuples: vec![vec![0, 2]], + }], + conjuncts: vec![(0, vec![QueryArg::Variable(2), QueryArg::Constant(2)])], + }) + .unwrap(); + + assert_eq!(problem.num_variables(), 3); + assert_eq!( + ConjunctiveBooleanQueryCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["domain_size", "relations", "conjuncts"] + ); +} + +#[test] +fn test_conjunctivebooleanquery_create_spec_rejects_invalid_relation_index() { + let result = ConjunctiveBooleanQuery::try_from(ConjunctiveBooleanQueryCreateSpec { + domain_size: 1, + relations: vec![], + conjuncts: vec![(0, vec![])], + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs index 949f2c75a..168592693 100644 --- a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs @@ -1,4 +1,18 @@ use super::*; + +#[test] +fn create_spec_defaults_known_values() { + let problem = ConsistencyOfDatabaseFrequencyTables::try_from( + ConsistencyOfDatabaseFrequencyTablesCreateSpec { + num_objects: 2, + attribute_domains: vec![2, 2], + frequency_tables: vec![FrequencyTable::new(0, 1, vec![vec![1, 0], vec![0, 1]])], + known_values: None, + }, + ) + .unwrap(); + assert!(problem.known_values().is_empty()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/grouping_by_swapping.rs b/src/unit_tests/models/misc/grouping_by_swapping.rs index 1436988be..56e45c250 100644 --- a/src/unit_tests/models/misc/grouping_by_swapping.rs +++ b/src/unit_tests/models/misc/grouping_by_swapping.rs @@ -106,3 +106,34 @@ fn test_grouping_by_swapping_symbol_out_of_range_panics() { fn test_grouping_by_swapping_empty_string_requires_zero_budget() { GroupingBySwapping::new(0, vec![], 1); } + +#[test] +fn test_grouping_by_swapping_create_spec_derives_alphabet_and_renames_bound() { + let problem = GroupingBySwapping::try_from(GroupingBySwappingCreateSpec { + alphabet_size: None, + string: vec![0, 2, 1], + bound: 4, + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.budget(), 4); + assert_eq!( + GroupingBySwappingCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "string", "bound"] + ); +} + +#[test] +fn test_grouping_by_swapping_create_spec_rejects_nonzero_bound_for_empty_string() { + let result = GroupingBySwapping::try_from(GroupingBySwappingCreateSpec { + alphabet_size: None, + string: vec![], + bound: 1, + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/job_shop_scheduling.rs b/src/unit_tests/models/misc/job_shop_scheduling.rs index 4b252f1d5..a3560fa75 100644 --- a/src/unit_tests/models/misc/job_shop_scheduling.rs +++ b/src/unit_tests/models/misc/job_shop_scheduling.rs @@ -91,3 +91,36 @@ fn test_job_shop_scheduling_brute_force_solver_small_instance() { let witness = solver.find_witness(&problem).unwrap(); assert_eq!(problem.evaluate(&witness), Min(Some(2))); } + +#[test] +fn test_job_shop_scheduling_create_spec_derives_processor_count() { + let problem = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![vec![(0, 2), (2, 1)]], + num_processors: None, + }) + .unwrap(); + + assert_eq!(problem.num_processors(), 3); + assert_eq!( + JobShopSchedulingCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["jobs", "num_processors"] + ); +} + +#[test] +fn test_job_shop_scheduling_create_spec_rejects_invalid_jobs() { + let empty = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![], + num_processors: None, + }); + assert!(empty.is_err()); + + let repeated_processor = JobShopScheduling::try_from(JobShopSchedulingCreateSpec { + jobs: vec![vec![(0, 1), (0, 2)]], + num_processors: Some(1), + }); + assert!(repeated_processor.is_err()); +} diff --git a/src/unit_tests/models/misc/knapsack.rs b/src/unit_tests/models/misc/knapsack.rs index ec75b7077..32f1e54ef 100644 --- a/src/unit_tests/models/misc/knapsack.rs +++ b/src/unit_tests/models/misc/knapsack.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_item_weights() { + let p = Knapsack::try_from(KnapsackCreateSpec { + weights: None, + values: vec![2, 3], + capacity: 1, + }) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/kth_largest_m_tuple.rs b/src/unit_tests/models/misc/kth_largest_m_tuple.rs index 57e252576..0afabef1d 100644 --- a/src/unit_tests/models/misc/kth_largest_m_tuple.rs +++ b/src/unit_tests/models/misc/kth_largest_m_tuple.rs @@ -1,4 +1,4 @@ -use crate::models::misc::KthLargestMTuple; +use super::*; use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; use crate::types::Or; @@ -8,6 +8,18 @@ fn example_problem(k: u64) -> KthLargestMTuple { KthLargestMTuple::new(vec![vec![2, 5, 8], vec![3, 6], vec![1, 4, 7]], k, 12) } +#[test] +fn test_kth_largest_m_tuple_create_spec_uses_subsets_input() { + assert_eq!(KthLargestMTupleCreateSpec::FIELDS[0].name, "subsets"); + let problem = KthLargestMTuple::try_from(KthLargestMTupleCreateSpec { + subsets: vec![vec![1], vec![2]], + k: 1, + bound: 3, + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![1], vec![2]]); +} + #[test] fn test_kth_largest_m_tuple_creation() { let p = example_problem(14); diff --git a/src/unit_tests/models/misc/longest_common_subsequence.rs b/src/unit_tests/models/misc/longest_common_subsequence.rs index 83747828f..56ec2e7ce 100644 --- a/src/unit_tests/models/misc/longest_common_subsequence.rs +++ b/src/unit_tests/models/misc/longest_common_subsequence.rs @@ -159,3 +159,32 @@ fn test_lcs_full_length_witness() { assert_eq!(problem.max_length(), 2); assert_eq!(problem.evaluate(&[0, 1]), Max(Some(2))); } + +#[test] +fn test_lcs_create_spec_derives_internal_fields() { + let problem = LongestCommonSubsequence::try_from(LongestCommonSubsequenceCreateSpec { + alphabet_size: None, + strings: vec![vec![0, 2], vec![2, 1, 0]], + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.max_length(), 2); + assert_eq!( + LongestCommonSubsequenceCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "strings"] + ); +} + +#[test] +fn test_lcs_create_spec_rejects_all_empty_strings() { + let result = LongestCommonSubsequence::try_from(LongestCommonSubsequenceCreateSpec { + alphabet_size: Some(2), + strings: vec![vec![], vec![]], + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/minimum_decision_tree.rs b/src/unit_tests/models/misc/minimum_decision_tree.rs index 6332ff56c..4d8e342e6 100644 --- a/src/unit_tests/models/misc/minimum_decision_tree.rs +++ b/src/unit_tests/models/misc/minimum_decision_tree.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_rejects_indistinguishable_objects() { + assert!( + MinimumDecisionTree::try_from(MinimumDecisionTreeCreateSpec { + test_matrix: vec![vec![false, false]], + num_objects: 2, + num_tests: 1 + }) + .is_err() + ); +} use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs index 704471b8d..83795f884 100644 --- a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs +++ b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs @@ -236,3 +236,21 @@ fn test_minimum_tardiness_sequencing_paper_example() { let problem = MinimumTardinessSequencing::::new(4, vec![2, 3, 1, 4], vec![(0, 2)]); assert_eq!(problem.evaluate(&[0, 0, 0, 0]), Min(Some(1))); } +#[test] +fn create_specs_default_precedences_to_empty() { + let unit = MinimumTardinessSequencing::try_from(MinimumTardinessSequencingOneCreateSpec { + lengths: vec![One, One], + deadlines: vec![1, 2], + precedences: None, + }) + .unwrap(); + let weighted = MinimumTardinessSequencing::try_from(MinimumTardinessSequencingI32CreateSpec { + lengths: vec![1, 2], + deadlines: vec![1, 3], + precedences: None, + }) + .unwrap(); + assert!(unit.precedences().is_empty()); + assert!(weighted.precedences().is_empty()); + assert!(!MinimumTardinessSequencingOneCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs index 1a708e8cf..f36009fb7 100644 --- a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs +++ b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_arc_weights() { + let p = MinimumWeightAndOrGraph::try_from(MinimumWeightAndOrGraphCreateSpec { + num_vertices: 2, + arcs: vec![(0, 1)], + source: 0, + gate_types: vec![Some(false), None], + arc_weights: None, + }) + .unwrap(); + assert_eq!(p.arc_weights(), &[1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/multiprocessor_scheduling.rs b/src/unit_tests/models/misc/multiprocessor_scheduling.rs index bbc5ff3d8..e3aad6483 100644 --- a/src/unit_tests/models/misc/multiprocessor_scheduling.rs +++ b/src/unit_tests/models/misc/multiprocessor_scheduling.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_zero_processors() { + assert_eq!( + MultiprocessorSchedulingCreateSpec::FIELDS[1].name, + "num_processors" + ); + assert!( + MultiprocessorScheduling::try_from(MultiprocessorSchedulingCreateSpec { + lengths: vec![1], + num_processors: 0, + deadline: 1 + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/open_shop_scheduling.rs b/src/unit_tests/models/misc/open_shop_scheduling.rs index 2c493cdac..18e1e174b 100644 --- a/src/unit_tests/models/misc/open_shop_scheduling.rs +++ b/src/unit_tests/models/misc/open_shop_scheduling.rs @@ -10,6 +10,20 @@ fn two_by_two() -> OpenShopScheduling { OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]) } +#[test] +fn test_open_shop_create_spec_uses_num_processors_input() { + assert_eq!( + OpenShopSchedulingCreateSpec::FIELDS[0].name, + "num_processors" + ); + let problem = OpenShopScheduling::try_from(OpenShopSchedulingCreateSpec { + num_processors: 2, + processing_times: vec![vec![1, 2]], + }) + .unwrap(); + assert_eq!(problem.num_machines(), 2); +} + /// 3 machines, 3 jobs: a small asymmetric instance. fn three_by_three() -> OpenShopScheduling { OpenShopScheduling::new(3, vec![vec![1, 2, 3], vec![3, 2, 1], vec![2, 1, 2]]) diff --git a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs index a354ec3aa..6949af168 100644 --- a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs +++ b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn create_spec_defaults_edge_weights() { + let p = + OptimumCommunicationSpanningTree::try_from(OptimumCommunicationSpanningTreeCreateSpec { + num_vertices: 2, + edge_weights: None, + requirements: vec![vec![0, 1], vec![1, 0]], + }) + .unwrap(); + assert_eq!(p.edge_weights(), &[vec![0, 1], vec![1, 0]]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/partially_ordered_knapsack.rs b/src/unit_tests/models/misc/partially_ordered_knapsack.rs index 9c8f907f2..47f9dbe19 100644 --- a/src/unit_tests/models/misc/partially_ordered_knapsack.rs +++ b/src/unit_tests/models/misc/partially_ordered_knapsack.rs @@ -200,3 +200,15 @@ fn test_partially_ordered_knapsack_negative_weight() { fn test_partially_ordered_knapsack_negative_value() { PartiallyOrderedKnapsack::new(vec![1, 2], vec![-3, 4], vec![], 5); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = PartiallyOrderedKnapsack::try_from(PartiallyOrderedKnapsackCreateSpec { + weights: vec![1, 2], + values: vec![3, 4], + precedences: None, + capacity: 2, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PartiallyOrderedKnapsackCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs index 8c30bc4b3..1d42716ad 100644 --- a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs @@ -133,3 +133,16 @@ fn test_precedence_constrained_scheduling_no_precedences() { .expect("should find a solution"); assert!(problem.evaluate(&solution)); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = + PrecedenceConstrainedScheduling::try_from(PrecedenceConstrainedSchedulingCreateSpec { + num_tasks: 2, + num_processors: 1, + deadline: 2, + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PrecedenceConstrainedSchedulingCreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/misc/preemptive_scheduling.rs b/src/unit_tests/models/misc/preemptive_scheduling.rs index d1e2f8809..f7673c6bb 100644 --- a/src/unit_tests/models/misc/preemptive_scheduling.rs +++ b/src/unit_tests/models/misc/preemptive_scheduling.rs @@ -229,3 +229,14 @@ fn test_preemptive_scheduling_deserialize_invalid_zero_processors() { let result: Result = serde_json::from_value(json); assert!(result.is_err()); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = PreemptiveScheduling::try_from(PreemptiveSchedulingCreateSpec { + lengths: vec![1, 2], + num_processors: 1, + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!PreemptiveSchedulingCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/production_planning.rs b/src/unit_tests/models/misc/production_planning.rs index 83be19188..f8ec13e50 100644 --- a/src/unit_tests/models/misc/production_planning.rs +++ b/src/unit_tests/models/misc/production_planning.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_rejects_period_vector_mismatch() { + assert_eq!(ProductionPlanningCreateSpec::FIELDS[0].name, "num_periods"); + assert!(ProductionPlanning::try_from(ProductionPlanningCreateSpec { + num_periods: 1, + demands: vec![], + capacities: vec![1], + setup_costs: vec![1], + production_costs: vec![1], + inventory_costs: vec![1], + cost_bound: 1 + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Or; diff --git a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs index d137878c6..bae28193d 100644 --- a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_task_weights() { + let p = SchedulingToMinimizeWeightedCompletionTime::try_from( + SchedulingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: vec![1, 2], + weights: None, + num_processors: 1, + }, + ) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs index 19ae0cab4..972fed615 100644 --- a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_deadline_count_mismatch() { + assert_eq!( + SchedulingWithIndividualDeadlinesCreateSpec::FIELDS[2].name, + "deadlines" + ); + assert!(SchedulingWithIndividualDeadlines::try_from( + SchedulingWithIndividualDeadlinesCreateSpec { + num_tasks: 2, + num_processors: 1, + deadlines: vec![1], + precedences: None + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; @@ -132,3 +149,16 @@ fn test_scheduling_with_individual_deadlines_mismatched_deadlines() { fn test_scheduling_with_individual_deadlines_invalid_precedence() { SchedulingWithIndividualDeadlines::new(3, 2, vec![1, 1, 1], vec![(4, 1)]); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = + SchedulingWithIndividualDeadlines::try_from(SchedulingWithIndividualDeadlinesCreateSpec { + num_tasks: 2, + num_processors: 1, + deadlines: vec![1, 2], + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!SchedulingWithIndividualDeadlinesCreateSpec::INPUTS[3].required); +} diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index a463b1a9b..b4d005fe8 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -1,4 +1,15 @@ use super::*; + +#[test] +fn create_spec_defaults_precedences() { + let problem = + SequencingToMinimizeMaximumCumulativeCost::try_from(SequencingCumulativeCostCreateSpec { + costs: vec![1, -1], + precedences: None, + }) + .unwrap(); + assert!(problem.precedences().is_empty()); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs index 023b18c75..7b93dd1e9 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -1,4 +1,17 @@ use super::*; + +#[test] +fn create_spec_defaults_task_weights() { + let p = SequencingToMinimizeTardyTaskWeight::try_from( + SequencingToMinimizeTardyTaskWeightCreateSpec { + lengths: vec![1, 2], + weights: None, + deadlines: vec![1, 3], + }, + ) + .unwrap(); + assert_eq!(p.weights(), &[1, 1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 58a40b00a..4eef77567 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -198,3 +198,16 @@ fn test_sequencing_to_minimize_weighted_completion_time_total_processing_time_ov SequencingToMinimizeWeightedCompletionTime::new(vec![u64::MAX, 1], vec![1, 1], vec![]); let _ = problem.total_processing_time(); } +#[test] +fn create_spec_defaults_precedences_to_empty() { + let problem = SequencingToMinimizeWeightedCompletionTime::try_from( + SequencingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: vec![1, 2], + weights: vec![3, 4], + precedences: None, + }, + ) + .unwrap(); + assert!(problem.precedences().is_empty()); + assert!(!SequencingToMinimizeWeightedCompletionTimeCreateSpec::INPUTS[2].required); +} diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs index b0d45b8f4..ea5bca499 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -1,4 +1,21 @@ use super::*; + +#[test] +fn create_spec_rejects_vector_length_mismatch() { + assert_eq!( + SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS[1].name, + "weights" + ); + assert!(SequencingToMinimizeWeightedTardiness::try_from( + SequencingToMinimizeWeightedTardinessCreateSpec { + lengths: vec![1], + weights: vec![], + deadlines: vec![1], + bound: 0 + } + ) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/sequencing_within_intervals.rs b/src/unit_tests/models/misc/sequencing_within_intervals.rs index a9a60ac2f..71410517c 100644 --- a/src/unit_tests/models/misc/sequencing_within_intervals.rs +++ b/src/unit_tests/models/misc/sequencing_within_intervals.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn create_spec_rejects_empty_window() { + assert_eq!( + SequencingWithinIntervalsCreateSpec::FIELDS[0].name, + "release_times" + ); + assert!( + SequencingWithinIntervals::try_from(SequencingWithinIntervalsCreateSpec { + release_times: vec![2], + deadlines: vec![2], + lengths: vec![1] + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/misc/shortest_common_supersequence.rs b/src/unit_tests/models/misc/shortest_common_supersequence.rs index 3a6117f9a..0d0bc8fd9 100644 --- a/src/unit_tests/models/misc/shortest_common_supersequence.rs +++ b/src/unit_tests/models/misc/shortest_common_supersequence.rs @@ -3,6 +3,57 @@ use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; +#[test] +fn test_shortestcommonsupersequence_create_spec_derives_stored_fields() { + let problem = ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![vec![0, 1], vec![1, 2]], + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 3); + assert_eq!(problem.strings(), &[vec![0, 1], vec![1, 2]]); + assert_eq!(problem.max_length(), 4); + + let entry = inventory::iter::() + .find(|entry| entry.name == "ShortestCommonSupersequence") + .unwrap(); + let inputs = entry.create_inputs.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].name, "strings"); + assert_eq!( + inputs[0].codec, + crate::registry::CreateInputCodec::SemicolonSeparated + ); + + let constructed = (entry.construct_fn)(serde_json::json!({ + "strings": [[0, 1], [1, 2]] + })) + .unwrap(); + let constructed = constructed + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(constructed.alphabet_size(), 3); + assert_eq!(constructed.max_length(), 4); +} + +#[test] +fn test_shortestcommonsupersequence_create_spec_rejects_invalid_input() { + let empty = ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![], + }); + assert_eq!(empty.unwrap_err(), "must have at least one string"); + + let overflowing_symbol = + ShortestCommonSupersequence::try_from(ShortestCommonSupersequenceCreateSpec { + strings: vec![vec![usize::MAX]], + }); + assert_eq!( + overflowing_symbol.unwrap_err(), + "alphabet size overflows usize" + ); +} + #[test] fn test_shortestcommonsupersequence_basic() { let problem = ShortestCommonSupersequence::new( diff --git a/src/unit_tests/models/misc/stacker_crane.rs b/src/unit_tests/models/misc/stacker_crane.rs index bfc89f9b6..e2b6a4262 100644 --- a/src/unit_tests/models/misc/stacker_crane.rs +++ b/src/unit_tests/models/misc/stacker_crane.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn create_spec_defaults_lengths_and_checks_inferred_vertex_counts() { + let problem = StackerCrane::try_from(StackerCraneCreateSpec { + arcs: vec![(0, 1)], + edges: vec![(1, 0)], + num_vertices: None, + arc_lengths: None, + edge_lengths: None, + }) + .unwrap(); + assert_eq!(problem.num_vertices(), 2); + assert_eq!(problem.arc_lengths(), &[1]); + assert_eq!(problem.edge_lengths(), &[1]); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::Min; diff --git a/src/unit_tests/models/misc/staff_scheduling.rs b/src/unit_tests/models/misc/staff_scheduling.rs index 7e36b205f..f363f03eb 100644 --- a/src/unit_tests/models/misc/staff_scheduling.rs +++ b/src/unit_tests/models/misc/staff_scheduling.rs @@ -2,6 +2,19 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_staff_scheduling_create_spec_uses_k_input() { + assert_eq!(StaffSchedulingCreateSpec::FIELDS[0].name, "k"); + let problem = StaffScheduling::try_from(StaffSchedulingCreateSpec { + k: 1, + schedules: vec![vec![true, false]], + requirements: vec![1, 0], + num_workers: 1, + }) + .unwrap(); + assert_eq!(problem.shifts_per_schedule(), 1); +} + fn issue_example_problem() -> StaffScheduling { StaffScheduling::new( 5, diff --git a/src/unit_tests/models/misc/string_to_string_correction.rs b/src/unit_tests/models/misc/string_to_string_correction.rs index f4023a730..ba6320604 100644 --- a/src/unit_tests/models/misc/string_to_string_correction.rs +++ b/src/unit_tests/models/misc/string_to_string_correction.rs @@ -149,3 +149,37 @@ fn test_string_to_string_correction_is_available_in_prelude() { let problem = crate::prelude::StringToStringCorrection::new(2, vec![0], vec![0], 0); assert!(problem.evaluate(&[])); } + +#[test] +fn test_string_to_string_correction_create_spec_derives_alphabet() { + let problem = StringToStringCorrection::try_from(StringToStringCorrectionCreateSpec { + alphabet_size: None, + source_string: vec![0, 3], + target_string: vec![3], + bound: 1, + }) + .unwrap(); + + assert_eq!(problem.alphabet_size(), 4); + assert_eq!(problem.source(), &[0, 3]); + assert_eq!(problem.target(), &[3]); + assert_eq!( + StringToStringCorrectionCreateSpec::FIELDS + .iter() + .map(|field| field.name) + .collect::>(), + ["alphabet_size", "source_string", "target_string", "bound"] + ); +} + +#[test] +fn test_string_to_string_correction_create_spec_rejects_small_alphabet() { + let result = StringToStringCorrection::try_from(StringToStringCorrectionCreateSpec { + alphabet_size: Some(2), + source_string: vec![2], + target_string: vec![], + bound: 1, + }); + + assert!(result.is_err()); +} diff --git a/src/unit_tests/models/misc/three_partition.rs b/src/unit_tests/models/misc/three_partition.rs index af70099a1..a8fc62e77 100644 --- a/src/unit_tests/models/misc/three_partition.rs +++ b/src/unit_tests/models/misc/three_partition.rs @@ -21,6 +21,20 @@ fn test_three_partition_basic() { assert_eq!(::variant(), vec![]); } +#[test] +fn test_three_partition_create_spec_preserves_u64_bound() { + let entry = crate::registry::find_variant_entry("ThreePartition", &Default::default()).unwrap(); + let problem = (entry.construct_fn)(serde_json::json!({ + "sizes": vec![6148914691236517205_u64; 3], + "bound": u64::MAX, + })) + .unwrap(); + assert_eq!( + problem.serialize_json()["bound"], + serde_json::json!(u64::MAX) + ); +} + #[test] fn test_three_partition_evaluate_yes_instance() { let problem = yes_problem(); diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 82f52d032..aba2eea19 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,4 +1,18 @@ -use crate::models::misc::TimetableDesign; +use super::*; + +#[test] +fn create_spec_rejects_matrix_shape_mismatch() { + assert_eq!(TimetableDesignCreateSpec::FIELDS[3].name, "craftsman_avail"); + assert!(TimetableDesign::try_from(TimetableDesignCreateSpec { + num_periods: 1, + num_craftsmen: 1, + num_tasks: 1, + craftsman_avail: vec![], + task_avail: vec![vec![true]], + requirements: vec![vec![1]] + }) + .is_err()); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/set/comparative_containment.rs b/src/unit_tests/models/set/comparative_containment.rs index c677fd45e..66d444b00 100644 --- a/src/unit_tests/models/set/comparative_containment.rs +++ b/src/unit_tests/models/set/comparative_containment.rs @@ -1,4 +1,27 @@ use super::*; + +#[test] +fn create_spec_defaults_weights_and_validates_sets() { + let problem = ComparativeContainment::::try_from(ComparativeContainmentI32CreateSpec { + universe_size: 2, + r_sets: vec![vec![0]], + s_sets: vec![vec![1]], + r_weights: None, + s_weights: None, + }) + .unwrap(); + assert_eq!(problem.r_weights(), &[1]); + assert!( + ComparativeContainment::::try_from(ComparativeContainmentI32CreateSpec { + universe_size: 1, + r_sets: vec![vec![1]], + s_sets: vec![], + r_weights: None, + s_weights: None + }) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::One; diff --git a/src/unit_tests/models/set/exact_cover_by_3_sets.rs b/src/unit_tests/models/set/exact_cover_by_3_sets.rs index fef828bfb..aad0abb88 100644 --- a/src/unit_tests/models/set/exact_cover_by_3_sets.rs +++ b/src/unit_tests/models/set/exact_cover_by_3_sets.rs @@ -1,4 +1,13 @@ use super::*; +#[test] +fn create_spec_sorts_triples() { + let problem = ExactCoverBy3Sets::try_from(ExactCoverBy3SetsCreateSpec { + universe_size: 3, + subsets: vec![[2, 0, 1]], + }) + .unwrap(); + assert_eq!(problem.subsets(), &[[0, 1, 2]]); +} use crate::solvers::BruteForce; use crate::traits::Problem; diff --git a/src/unit_tests/models/set/maximum_set_packing.rs b/src/unit_tests/models/set/maximum_set_packing.rs index 3f5a67f48..edda4ab10 100644 --- a/src/unit_tests/models/set/maximum_set_packing.rs +++ b/src/unit_tests/models/set/maximum_set_packing.rs @@ -4,6 +4,21 @@ use crate::traits::Problem; use crate::types::Max; include!("../../jl_helpers.rs"); +#[test] +fn test_maximum_set_packing_create_spec_uses_subsets_input() { + assert_eq!( + MaximumSetPackingCreateSpec::::FIELDS[0].name, + "subsets" + ); + let problem = MaximumSetPacking::try_from(MaximumSetPackingCreateSpec { + subsets: vec![vec![0], vec![1]], + weights: vec![2, 3], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0], vec![1]]); + assert_eq!(problem.weights_ref(), &[2, 3]); +} + #[test] fn test_set_packing_creation() { let problem = MaximumSetPacking::::new(vec![vec![0, 1], vec![1, 2], vec![3, 4]]); diff --git a/src/unit_tests/models/set/minimum_hitting_set.rs b/src/unit_tests/models/set/minimum_hitting_set.rs index 576f39b44..b132821dc 100644 --- a/src/unit_tests/models/set/minimum_hitting_set.rs +++ b/src/unit_tests/models/set/minimum_hitting_set.rs @@ -20,6 +20,17 @@ fn issue_example_problem() -> MinimumHittingSet { ) } +#[test] +fn test_minimum_hitting_set_create_spec_uses_subsets_input() { + assert_eq!(MinimumHittingSetCreateSpec::FIELDS[1].name, "subsets"); + let problem = MinimumHittingSet::try_from(MinimumHittingSetCreateSpec { + universe_size: 3, + subsets: vec![vec![0, 2]], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0, 2]]); +} + fn issue_example_config() -> Vec { vec![0, 1, 0, 1, 1, 0] } diff --git a/src/unit_tests/models/set/minimum_set_covering.rs b/src/unit_tests/models/set/minimum_set_covering.rs index bee8eeed5..5878d4062 100644 --- a/src/unit_tests/models/set/minimum_set_covering.rs +++ b/src/unit_tests/models/set/minimum_set_covering.rs @@ -4,6 +4,19 @@ use crate::traits::Problem; use crate::types::Min; include!("../../jl_helpers.rs"); +#[test] +fn test_minimum_set_covering_create_spec_uses_subsets_input() { + assert_eq!(MinimumSetCoveringCreateSpec::FIELDS[1].name, "subsets"); + let problem = MinimumSetCovering::try_from(MinimumSetCoveringCreateSpec { + universe_size: 2, + subsets: vec![vec![0], vec![1]], + weights: vec![2, 3], + }) + .unwrap(); + assert_eq!(problem.sets(), &[vec![0], vec![1]]); + assert_eq!(problem.weights_ref(), &[2, 3]); +} + #[test] fn test_set_covering_creation() { let problem = MinimumSetCovering::::new(4, vec![vec![0, 1], vec![1, 2], vec![2, 3]]); diff --git a/src/unit_tests/models/set/prime_attribute_name.rs b/src/unit_tests/models/set/prime_attribute_name.rs index b8999597c..6676e12af 100644 --- a/src/unit_tests/models/set/prime_attribute_name.rs +++ b/src/unit_tests/models/set/prime_attribute_name.rs @@ -2,6 +2,21 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_prime_attribute_create_spec_uses_universe_size_input() { + assert_eq!( + PrimeAttributeNameCreateSpec::FIELDS[0].name, + "universe_size" + ); + let problem = PrimeAttributeName::try_from(PrimeAttributeNameCreateSpec { + universe_size: 2, + dependencies: vec![(vec![0], vec![1])], + query_attribute: 0, + }) + .unwrap(); + assert_eq!(problem.num_attributes(), 2); +} + /// Helper: Issue Example 1 — 6 attributes, 3 FDs, query=3 /// Candidate keys: {0,1}, {2,3}, {0,3} — attribute 3 is prime fn example1() -> PrimeAttributeName { diff --git a/src/unit_tests/models/set/set_basis.rs b/src/unit_tests/models/set/set_basis.rs index ff4bb3a27..08427367f 100644 --- a/src/unit_tests/models/set/set_basis.rs +++ b/src/unit_tests/models/set/set_basis.rs @@ -11,6 +11,18 @@ fn issue_example_problem(k: usize) -> SetBasis { ) } +#[test] +fn test_set_basis_create_spec_uses_subsets_input() { + assert_eq!(SetBasisCreateSpec::FIELDS[1].name, "subsets"); + let problem = SetBasis::try_from(SetBasisCreateSpec { + universe_size: 3, + subsets: vec![vec![0, 2]], + k: 1, + }) + .unwrap(); + assert_eq!(problem.collection(), &[vec![0, 2]]); +} + fn canonical_solution() -> Vec { vec![1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0] } diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 44bac3c0d..8950d9332 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -76,9 +76,10 @@ fn test_schema_json_serialization() { fn test_field_info_json_fields() { let schemas = collect_schemas(); let sg = schemas.iter().find(|s| s.name == "SpinGlass").unwrap(); - assert_eq!(sg.fields.len(), 3); + assert_eq!(sg.fields.len(), 4); let field_names: Vec<&str> = sg.fields.iter().map(|f| f.name.as_str()).collect(); assert!(field_names.contains(&"graph")); + assert!(field_names.contains(&"num_vertices")); assert!(field_names.contains(&"couplings")); assert!(field_names.contains(&"fields")); for f in &sg.fields { diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index f8ec9d944..ec36272f4 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -1,5 +1,8 @@ -use crate::registry::variant::{validate_variant_aliases, variant_label}; -use std::collections::BTreeMap; +use crate::registry::variant::{ + validate_create_inputs, validate_direct_create_inputs, validate_variant_aliases, variant_label, +}; +use crate::registry::{ConstructionError, CreateInputCodec, CreateInputInfo, FieldInfo}; +use std::collections::{BTreeMap, BTreeSet}; #[test] fn variant_alias_inventory_is_valid() { @@ -16,6 +19,193 @@ fn empty_problem_names() -> BTreeMap> { BTreeMap::new() } +const CREATE_INPUTS: &[CreateInputInfo] = &[ + CreateInputInfo { + name: "required_value", + type_name: "usize", + description: "A required value", + required: true, + codec: CreateInputCodec::Scalar, + }, + CreateInputInfo { + name: "optional_value", + type_name: "usize", + description: "An optional value", + required: false, + codec: CreateInputCodec::Scalar, + }, +]; + +#[test] +fn construction_contract_accepts_declared_inputs() { + let data = serde_json::json!({"required_value": 1, "optional_value": 2}); + assert_eq!(validate_create_inputs(CREATE_INPUTS, &data), Ok(())); +} + +#[test] +fn construction_contract_rejects_unknown_inputs() { + let data = serde_json::json!({"required_value": 1, "removed_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::UnknownInputs(vec![ + "removed_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_missing_required_inputs() { + let data = serde_json::json!({"optional_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::MissingInputs(vec![ + "required_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_non_object_values() { + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &serde_json::json!([])), + Err(ConstructionError::ExpectedObject) + ); +} + +#[test] +fn construction_contract_rejects_duplicate_declarations() { + let duplicate = [CREATE_INPUTS[0], CREATE_INPUTS[0]]; + assert_eq!( + validate_create_inputs(&duplicate, &serde_json::json!({"required_value": 1})), + Err(ConstructionError::DuplicateInput( + "required_value".to_string() + )) + ); +} + +#[test] +fn catalog_custom_construction_metadata_is_well_formed() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + let label = variant_label(entry); + let mut names = BTreeSet::new(); + for input in inputs { + assert!( + !input.name.is_empty(), + "{label} declares an empty construction input name" + ); + assert!( + input + .name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()), + "{label} construction input `{}` must use snake_case", + input.name + ); + assert!( + names.insert(input.name), + "{label} declares construction input `{}` more than once", + input.name + ); + assert!( + !input.type_name.trim().is_empty(), + "{label} construction input `{}` has no Rust type", + input.name + ); + assert_eq!( + input.description, + input.description.trim(), + "{label} construction input `{}` has surrounding whitespace in its description", + input.name + ); + } + } +} + +#[test] +fn default_custom_construction_inputs_match_catalog_schema_fields() { + for entry in inventory::iter::() + .filter(|entry| entry.is_default && entry.create_inputs.is_some()) + { + let schema = inventory::iter::() + .find(|schema| schema.name == entry.name) + .unwrap_or_else(|| panic!("{} has no ProblemSchemaEntry", entry.name)); + let schema_names = schema + .fields + .iter() + .map(|field| field.name) + .collect::>(); + let input_names = entry + .create_inputs + .unwrap() + .iter() + .map(|input| input.name) + .collect::>(); + assert_eq!( + schema_names, + input_names, + "default variant {} catalog fields differ from its construction inputs", + variant_label(entry) + ); + } +} + +#[test] +fn every_custom_construction_contract_rejects_unknown_and_missing_inputs() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + assert_eq!( + validate_create_inputs(inputs, &serde_json::json!({"unknown_input": null})), + Err(ConstructionError::UnknownInputs(vec![ + "unknown_input".to_string() + ])), + "{} accepted an undeclared construction input", + variant_label(entry) + ); + + let required = inputs + .iter() + .filter(|input| input.required) + .map(|input| input.name.to_string()) + .collect::>() + .into_iter() + .collect::>(); + let result = validate_create_inputs(inputs, &serde_json::json!({})); + if required.is_empty() { + assert_eq!( + result, + Ok(()), + "{} rejected an empty payload", + variant_label(entry) + ); + } else { + assert_eq!( + result, + Err(ConstructionError::MissingInputs(required)), + "{} did not report all missing required inputs", + variant_label(entry) + ); + } + } +} + +#[test] +fn construction_contract_direct_fields_are_required() { + let fields = [FieldInfo { + name: "value", + type_name: "usize", + description: "Stored value", + }]; + assert_eq!( + validate_direct_create_inputs(&fields, &serde_json::json!({})), + Err(ConstructionError::MissingInputs(vec!["value".to_string()])) + ); +} + #[test] fn validate_inner_accepts_valid_aliases() { let entries = vec![ From c90c817f98e6c464e53428a2bb1713a73dcd4354 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 11 Aug 2026 23:05:44 +0800 Subject: [PATCH 3/7] refactor: make random generation model-owned --- .claude/CLAUDE.md | 1 + .claude/skills/add-model/SKILL.md | 18 +- .claude/skills/add-rule/SKILL.md | 4 +- .claude/skills/find-solver/SKILL.md | 4 +- .claude/skills/review-pipeline/SKILL.md | 2 +- .claude/skills/review-structural/SKILL.md | 4 +- .claude/skills/run-pipeline/SKILL.md | 4 +- problemreductions-cli/src/cli.rs | 24 +- problemreductions-cli/src/commands/create.rs | 661 +----------------- .../src/commands/create/schema_support.rs | 84 +-- .../src/commands/create/tests.rs | 9 +- problemreductions-cli/src/commands/graph.rs | 232 +++++- problemreductions-cli/src/main.rs | 12 +- problemreductions-cli/src/mcp/tools.rs | 300 ++------ problemreductions-cli/src/test_support.rs | 4 + problemreductions-cli/src/util.rs | 205 ------ problemreductions-cli/tests/cli_tests.rs | 54 +- problemreductions-macros/src/lib.rs | 28 +- src/lib.rs | 1 + src/models/decision.rs | 16 +- .../graph/bottleneck_traveling_salesman.rs | 12 +- src/models/graph/generalized_hex.rs | 11 +- src/models/graph/hamiltonian_circuit.rs | 8 +- src/models/graph/hamiltonian_path.rs | 8 +- .../hamiltonian_path_between_two_vertices.rs | 40 +- src/models/graph/kclique.rs | 16 +- src/models/graph/kcoloring.rs | 34 +- .../graph/length_bounded_disjoint_paths.rs | 43 +- src/models/graph/longest_circuit.rs | 8 +- src/models/graph/max_cut.rs | 8 +- src/models/graph/maximal_is.rs | 6 +- src/models/graph/maximum_achromatic_number.rs | 8 +- src/models/graph/maximum_clique.rs | 11 +- src/models/graph/maximum_domatic_number.rs | 8 +- src/models/graph/maximum_independent_set.rs | 36 +- .../graph/maximum_leaf_spanning_tree.rs | 13 +- src/models/graph/maximum_matching.rs | 8 +- .../graph/minimum_covering_by_cliques.rs | 8 +- .../graph/minimum_cut_into_bounded_sets.rs | 9 +- src/models/graph/minimum_dominating_set.rs | 13 +- .../graph/minimum_intersection_graph_basis.rs | 8 +- src/models/graph/minimum_maximal_matching.rs | 8 +- src/models/graph/minimum_sum_multicenter.rs | 28 +- src/models/graph/minimum_vertex_cover.rs | 48 +- .../graph/optimal_linear_arrangement.rs | 8 +- src/models/graph/rooted_tree_arrangement.rs | 31 +- src/models/graph/spin_glass.rs | 8 +- src/models/graph/steiner_tree.rs | 18 +- src/models/graph/steiner_tree_in_graphs.rs | 12 +- src/models/graph/traveling_salesman.rs | 8 +- src/random.rs | 239 +++++++ src/registry/mod.rs | 23 +- src/registry/problem_type.rs | 7 + src/registry/variant.rs | 13 + src/unit_tests/registry/variant.rs | 58 +- 55 files changed, 1241 insertions(+), 1251 deletions(-) create mode 100644 src/random.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8303f0a42..9ac350bc8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -206,6 +206,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - 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 registry-driven and deferred:** `pred create` expands flags only for the selected concrete variant. Ordinary models use `ProblemSchemaEntry.fields` directly. Models whose construction differs from persisted JSON own a typed `CreateSpec` and fallible conversion beside the model; CLI and MCP only normalize transport values and invoke the registered constructor. - **Each construction input has one name and one concrete type per variant.** Do not add compatibility aliases or infer types from flag names. `CreateSpec` field names render as `snake_case → kebab-case` in CLI and remain `snake_case` in MCP. Add a reusable codec only for a genuinely new transport representation, never a model-name parser branch. +- **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` diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 4440019d0..9fb5b5050 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -168,10 +168,7 @@ 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 - -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) + - 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 @@ -187,6 +184,17 @@ CLI and MCP construction are registry-driven. Do not edit either frontend to rec 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. +### Optional random generation + +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. + +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. + +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 Add a builder function in `src/example_db/model_builders.rs` that constructs a small, canonical instance for this model. Register it in `build_model_examples()`. @@ -312,7 +320,7 @@ 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 | | 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. | 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/src/cli.rs b/problemreductions-cli/src/cli.rs index 5350dca08..5d8678057 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -49,16 +49,34 @@ pub struct Cli { #[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 scheduling + #[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) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 04fb1dbed..e57d771e4 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -7,17 +7,11 @@ use anyhow::{bail, Context, Result}; use num_bigint::BigUint; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::formula::Quantifier; -use problemreductions::models::graph::{ - GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, - LabelledArc, LabelledDigraph, LengthBoundedDisjointPaths, LongestCircuit, - MinimumCutIntoBoundedSets, MinimumMaximalMatching, RootedTreeArrangement, SteinerTree, - SteinerTreeInGraphs, -}; +use problemreductions::models::graph::{LabelledArc, LabelledDigraph}; use problemreductions::models::misc::{CbqRelation, FrequencyTable, KnownValue, QueryArg}; -use problemreductions::models::Decision; use problemreductions::prelude::*; use problemreductions::topology::{ - DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, + DirectedGraph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -354,7 +348,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { crate::create_args::resolve_registered_create_variant(problem); if args.has("random") { - return create_random(args, canonical, &resolved_variant, out); + return create_registered_random(args, canonical, &resolved_variant, out); } // ILP and CircuitSAT have complex input structures @@ -380,77 +374,43 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { emit_problem_output(&output, out) } -/// Create a vertex-weight problem dispatching on geometry graph type. -/// Serialize a vertex-weight problem with a generic graph type. -fn ser_vertex_weight_problem_with( +fn create_registered_random( + args: &CreateArgs, canonical: &str, - graph: G, - weights: Vec, -) -> Result { - match canonical { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights)), - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights)), - "MaximumClique" => ser(MaximumClique::new(graph, weights)), - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights)), - "MaximalIS" => ser(MaximalIS::new(graph, weights)), - _ => unreachable!(), - } -} - -fn ser_decision_minimum_vertex_cover_with< - G: Graph + Serialize + problemreductions::variant::VariantParam, ->( - graph: G, - weights: Vec, - bound: i64, -) -> Result { - ser(Decision::new( - MinimumVertexCover::new(graph, weights), - bound, - )) -} - -fn ser(problem: T) -> Result { - util::ser(problem) -} - -fn parse_kclique_threshold( - k_flag: Option, - num_vertices: usize, - usage: &str, -) -> Result { - let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires --k\n\n{usage}"))?; - if k == 0 { - bail!("KClique: --k must be positive"); - } - if k > num_vertices { - bail!("KClique: k must be <= graph num_vertices"); - } - Ok(k) -} - -fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { - util::variant_map(pairs) -} - -fn ensure_vertex_in_bounds(vertex: usize, num_vertices: usize, label: &str) -> Result<()> { - if vertex >= num_vertices { - bail!("{label} {vertex} out of bounds (graph has {num_vertices} vertices)"); - } - Ok(()) -} - -fn validate_vertex_index( - label: &str, - vertex: usize, - num_vertices: usize, - usage: &str, + resolved_variant: &BTreeMap, + out: &OutputConfig, ) -> Result<()> { - if vertex < num_vertices { - return Ok(()); - } - - bail!("{label} must be less than num_vertices ({num_vertices})\n\n{usage}"); + let entry = problemreductions::registry::find_variant_entry(canonical, resolved_variant) + .ok_or_else(|| { + anyhow::anyhow!( + "No concrete variant is registered for {canonical} with {resolved_variant:?}" + ) + })?; + let inputs = entry.random_inputs.ok_or_else(|| { + anyhow::anyhow!( + "Random generation is not registered for {}", + problemreductions::registry::variant::variant_label(entry) + ) + })?; + let data = normalize_registered_create_inputs(args, inputs, resolved_variant) + .map_err(|error| with_registered_usage(error, canonical, inputs))?; + let problem = + problemreductions::registry::generate_random_dyn(canonical, resolved_variant, data) + .map_err(|error| with_registered_usage(error.into(), canonical, inputs))?; + let variant = problem.variant_map(); + anyhow::ensure!( + problem.problem_name() == canonical && variant == *resolved_variant, + "registered random generator for {canonical} {resolved_variant:?} returned {} {variant:?}", + problem.problem_name(), + ); + emit_problem_output( + &ProblemJsonOutput { + problem_type: canonical.to_string(), + variant, + data: problem.serialize_json(), + }, + out, + ) } /// Parse `--dependencies` as semicolon-separated "lhs>rhs" pairs. @@ -562,551 +522,6 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } -/// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. -pub(super) fn supports_random(name: &str) -> bool { - matches!( - name, - "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" - ) -} - -/// Handle `pred create --random ...` -fn create_random( - args: &CreateArgs, - canonical: &str, - resolved_variant: &BTreeMap, - out: &OutputConfig, -) -> Result<()> { - let num_vertices = args.value::("num-vertices").ok_or_else(|| { - anyhow::anyhow!( - "--random requires --num-vertices\n\n\ - Usage: pred create {} --random --num-vertices 10 [--edge-prob 0.3] [--seed 42]", - canonical - ) - })?; - - let graph_type = resolved_graph_type(resolved_variant); - - let (data, variant) = match canonical { - "DecisionMinimumVertexCover" => { - let raw_bound = args.value::("bound").ok_or_else(|| { - anyhow::anyhow!( - "DecisionMinimumVertexCover requires --bound\n\n\ - Usage: pred create DecisionMinimumVertexCover --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --bound 3" - ) - })?; - anyhow::ensure!( - raw_bound >= 0, - "DecisionMinimumVertexCover: --bound must be non-negative" - ); - let bound = raw_bound; - let weights = vec![1i32; num_vertices]; - match graph_type { - "KingsSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.value::("seed")); - let graph = KingsSubgraph::new(positions); - ( - ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, - resolved_variant.clone(), - ) - } - "TriangularSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.value::("seed")); - let graph = TriangularSubgraph::new(positions); - ( - ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, - resolved_variant.clone(), - ) - } - "UnitDiskGraph" => { - let positions = util::create_random_float_positions(num_vertices, args.value::("seed")); - let radius = args.value::("radius").unwrap_or(1.5); - let graph = UnitDiskGraph::new(positions, radius); - ( - ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, - resolved_variant.clone(), - ) - } - _ => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - ( - ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, - resolved_variant.clone(), - ) - } - } - } - - // Graph problems with vertex weights - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" - | "MaximalIS" => { - let weights = vec![1i32; num_vertices]; - match graph_type { - "KingsSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.value::("seed")); - let graph = KingsSubgraph::new(positions); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "TriangularSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, args.value::("seed")); - let graph = TriangularSubgraph::new(positions); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "UnitDiskGraph" => { - let radius = args.value::("radius").unwrap_or(1.0); - let positions = util::create_random_float_positions(num_vertices, args.value::("seed")); - let graph = UnitDiskGraph::new(positions, radius); - ( - ser_vertex_weight_problem_with(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - _ => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = ser_vertex_weight_problem_with(canonical, graph, weights)?; - (data, variant) - } - } - } - - "KClique" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let usage = - "Usage: pred create KClique --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; - let k = parse_kclique_threshold(args.value::("k"), graph.num_vertices(), usage)?; - ( - ser(KClique::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - - // MinimumCutIntoBoundedSets (graph + edge weights + s/t/B/K) - "MinimumCutIntoBoundedSets" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - let source = 0; - let sink = num_vertices.saturating_sub(1); - let size_bound = num_vertices; // no effective size constraint - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(MinimumCutIntoBoundedSets::new( - graph, - edge_weights, - source, - sink, - size_bound, - ))?, - variant, - ) - } - - // MaximumAchromaticNumber (graph only, no weights) - "MaximumAchromaticNumber" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumAchromaticNumber::new(graph))?, - variant, - ) - } - - // MaximumDomaticNumber (graph only, no weights) - "MaximumDomaticNumber" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumDomaticNumber::new(graph))?, - variant, - ) - } - - // MinimumCoveringByCliques (graph only, no weights) - "MinimumCoveringByCliques" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MinimumCoveringByCliques::new(graph))?, - variant, - ) - } - - // MinimumIntersectionGraphBasis (graph only, no weights) - "MinimumIntersectionGraphBasis" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MinimumIntersectionGraphBasis::new(graph))?, - variant, - ) - } - - // MinimumMaximalMatching (graph only, no weights) - "MinimumMaximalMatching" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(MinimumMaximalMatching::new(graph))?, variant) - } - - // Hamiltonian Circuit (graph only, no weights) - "HamiltonianCircuit" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(HamiltonianCircuit::new(graph))?, variant) - } - - // Maximum Leaf Spanning Tree (graph only, no weights) - "MaximumLeafSpanningTree" => { - let num_vertices = num_vertices.max(2); - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(problemreductions::models::graph::MaximumLeafSpanningTree::new(graph))?, - variant, - ) - } - - // HamiltonianPath (graph only, no weights) - "HamiltonianPath" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(HamiltonianPath::new(graph))?, variant) - } - - // HamiltonianPathBetweenTwoVertices (graph + source/target) - "HamiltonianPathBetweenTwoVertices" => { - let num_vertices = num_vertices.max(2); - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let source_vertex = args.value::("source-vertex").unwrap_or(0); - let target_vertex = args.value::("target-vertex") - .unwrap_or_else(|| num_vertices.saturating_sub(1)); - ensure_vertex_in_bounds(source_vertex, graph.num_vertices(), "source_vertex")?; - ensure_vertex_in_bounds(target_vertex, graph.num_vertices(), "target_vertex")?; - anyhow::ensure!( - source_vertex != target_vertex, - "source_vertex and target_vertex must be distinct" - ); - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(HamiltonianPathBetweenTwoVertices::new( - graph, - source_vertex, - target_vertex, - ))?, - variant, - ) - } - - // LongestCircuit (graph + unit edge lengths) - "LongestCircuit" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let edge_lengths = vec![1i32; graph.num_edges()]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - (ser(LongestCircuit::new(graph, edge_lengths))?, variant) - } - - // GeneralizedHex (graph only, with source/sink defaults) - "GeneralizedHex" => { - let num_vertices = num_vertices.max(2); - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let source = args.value::("source").unwrap_or(0); - let sink = args.value::("sink").unwrap_or(num_vertices - 1); - let usage = "Usage: pred create GeneralizedHex --random --num-vertices 6 [--edge-prob 0.5] [--seed 42] [--source 0] [--sink 5]"; - validate_vertex_index("source", source, num_vertices, usage)?; - validate_vertex_index("sink", sink, num_vertices, usage)?; - if source == sink { - bail!("GeneralizedHex requires distinct --source and --sink\n\n{usage}"); - } - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(GeneralizedHex::new(graph, source, sink))?, variant) - } - - // LengthBoundedDisjointPaths (graph only, with path defaults) - "LengthBoundedDisjointPaths" => { - let num_vertices = if num_vertices < 2 { - eprintln!( - "Warning: LengthBoundedDisjointPaths requires at least 2 vertices; rounding {} up to 2", - num_vertices - ); - 2 - } else { - num_vertices - }; - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let source = args.value::("source").unwrap_or(0); - let sink = args.value::("sink").unwrap_or(num_vertices - 1); - let bound = args - .value::("max-length") - .unwrap_or((num_vertices - 1) as i64); - let max_length = validate_length_bounded_disjoint_paths_args( - num_vertices, - source, - sink, - bound, - None, - )?; - let variant = variant_map(&[("graph", "SimpleGraph")]); - ( - ser(LengthBoundedDisjointPaths::new( - graph, - source, - sink, - max_length, - ))?, - variant, - ) - } - - // Graph problems with edge weights - "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - let variant = match canonical { - "BottleneckTravelingSalesman" => variant_map(&[]), - _ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]), - }; - let data = match canonical { - "BottleneckTravelingSalesman" => { - ser(BottleneckTravelingSalesman::new(graph, edge_weights))? - } - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - (data, variant) - } - - // SteinerTreeInGraphs - "SteinerTreeInGraphs" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - // Use first half of vertices as terminals (at least 2) - let num_terminals = std::cmp::max(2, num_vertices / 2); - let terminals: Vec = (0..num_terminals).collect(); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SteinerTreeInGraphs::new(graph, terminals, edge_weights))?, - variant, - ) - } - - // SteinerTree - "SteinerTree" => { - anyhow::ensure!( - num_vertices >= 2, - "SteinerTree random generation requires --num-vertices >= 2" - ); - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let mut state = util::lcg_init(args.value::("seed")); - let graph = util::create_random_graph(num_vertices, edge_prob, Some(state)); - // Advance state past the graph generation - for _ in 0..num_vertices * num_vertices { - util::lcg_step(&mut state); - } - let edge_weights: Vec = (0..graph.num_edges()) - .map(|_| (util::lcg_step(&mut state) * 9.0) as i32 + 1) - .collect(); - let num_terminals = std::cmp::max(2, num_vertices * 2 / 5); - let terminals = util::lcg_choose(&mut state, num_vertices, num_terminals); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SteinerTree::new(graph, edge_weights, terminals))?, - variant, - ) - } - - // SpinGlass - "SpinGlass" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let num_edges = graph.num_edges(); - let couplings = vec![1i32; num_edges]; - let fields = vec![0i32; num_vertices]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - variant, - ) - } - - // KColoring - "KColoring" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let (k, _variant) = - util::validate_k_param(resolved_variant, args.value::("k"), Some(3), "KColoring")?; - util::ser_kcoloring(graph, k)? - } - - // OptimalLinearArrangement — graph only (optimization) - "OptimalLinearArrangement" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(OptimalLinearArrangement::new(graph))?, variant) - } - - // RootedTreeArrangement — graph + bound - "RootedTreeArrangement" => { - let edge_prob = args.value::("edge-prob").unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.value::("seed")); - let n = graph.num_vertices(); - let usage = "Usage: pred create RootedTreeArrangement --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] [--bound 10]"; - let bound = args.value::("bound") - .map(|b| parse_nonnegative_usize_bound(b, "RootedTreeArrangement", usage)) - .transpose()? - .unwrap_or((n.saturating_sub(1)) * graph.num_edges()); - let variant = variant_map(&[("graph", "SimpleGraph")]); - (ser(RootedTreeArrangement::new(graph, bound))?, variant) - } - - _ => bail!( - "Random generation is not supported for {canonical}. \ - Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, DecisionMinimumVertexCover, TravelingSalesman, \ - BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, MaximumLeafSpanningTree, SteinerTree, \ - OptimalLinearArrangement, RootedTreeArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)" - ), - }; - - let output = ProblemJsonOutput { - problem_type: canonical.to_string(), - variant, - data, - }; - - emit_problem_output(&output, out) -} - /// Parse implication rules from semicolon-separated "antecedents>consequent" strings. /// /// Format: "0,1>2;3>4;5,6,7>0" where antecedents are comma-separated indices diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index bf2c8e896..06862bf99 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -161,7 +161,7 @@ fn construct_canonical( Ok((problem.serialize_json(), constructed_variant)) } -fn normalize_registered_create_inputs( +pub(super) fn normalize_registered_create_inputs( args: &CreateArgs, inputs: &[problemreductions::registry::CreateInputInfo], resolved_variant: &BTreeMap, @@ -356,16 +356,21 @@ pub(crate) fn create_inputs_for( ); } } - if super::supports_random(canonical) { - for (name, kind) in [ - ("random", InputValueKind::Bool), - ("num-vertices", InputValueKind::Usize), - ("edge-prob", InputValueKind::F64), - ("seed", InputValueKind::U64), - ] { - if !inputs.contains_key(name) { - insert_create_input(&mut inputs, name, kind, "random generation"); - } + if let Some(random_inputs) = variant_entry.random_inputs { + 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, + ); } } @@ -383,9 +388,8 @@ fn insert_create_input( ) { if let Some((existing_kind, existing_source)) = inputs.get(name) { assert_eq!( - (*existing_kind, existing_source.as_str()), - (kind, source), - "create input --{name} is produced by both `{existing_source}` and `{source}`" + *existing_kind, kind, + "create input --{name} has conflicting types from `{existing_source}` and `{source}`" ); return; } @@ -475,7 +479,7 @@ pub(super) fn with_schema_usage( anyhow::anyhow!("{message}\n\nUsage: pred create {canonical} {flags}",) } -fn with_registered_usage( +pub(super) fn with_registered_usage( error: anyhow::Error, canonical: &str, inputs: &[problemreductions::registry::CreateInputInfo], @@ -1216,15 +1220,6 @@ pub(super) fn help_flag_name(field_name: &str) -> String { field_name.replace("_", "-") } -pub(super) fn parse_nonnegative_usize_bound( - bound: i64, - problem_name: &str, - usage: &str, -) -> Result { - usize::try_from(bound) - .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) -} - pub(super) fn problem_help_flag_name( field_name: &str, field_type: &str, @@ -1238,44 +1233,3 @@ pub(super) fn problem_help_flag_name( help_flag_name(field_name) } } - -pub(super) fn lbdp_validation_error(message: &str, usage: Option<&str>) -> anyhow::Error { - match usage { - Some(usage) => anyhow::anyhow!("{message}\n\n{usage}"), - None => anyhow::anyhow!("{message}"), - } -} - -pub(super) fn validate_length_bounded_disjoint_paths_args( - num_vertices: usize, - source: usize, - sink: usize, - bound: i64, - usage: Option<&str>, -) -> Result { - let max_length = usize::try_from(bound).map_err(|_| { - lbdp_validation_error( - "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths", - usage, - ) - })?; - if source >= num_vertices || sink >= num_vertices { - return Err(lbdp_validation_error( - "--source and --sink must be valid graph vertices", - usage, - )); - } - if source == sink { - return Err(lbdp_validation_error( - "--source and --sink must be distinct", - usage, - )); - } - if max_length == 0 { - return Err(lbdp_validation_error( - "--max-length must be positive", - usage, - )); - } - Ok(max_length) -} diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 70e5d7834..828cc4994 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -340,7 +340,7 @@ fn test_create_schema_driven_builds_closest_vector_problem_with_default_bounds() panic!("expected create command"); }; - let resolved_variant = variant_map(&[("weight", "i32")]); + let resolved_variant = BTreeMap::from([("weight".to_string(), "i32".to_string())]); let (data, variant) = create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) .expect("schema-driven create should parse"); @@ -450,7 +450,7 @@ fn test_create_schema_driven_builds_mixed_chinese_postman() { panic!("expected create command"); }; - let resolved_variant = variant_map(&[("weight", "i32")]); + let resolved_variant = BTreeMap::from([("weight".to_string(), "i32".to_string())]); let (data, variant) = create_schema_driven(&args, "MixedChinesePostman", &resolved_variant) .expect("schema-driven create should parse"); @@ -476,7 +476,10 @@ fn test_create_schema_driven_builds_unit_disk_graph_problem_with_default_radius( panic!("expected create command"); }; - let resolved_variant = variant_map(&[("graph", "UnitDiskGraph"), ("weight", "One")]); + let resolved_variant = BTreeMap::from([ + ("graph".to_string(), "UnitDiskGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]); let (data, variant) = create_schema_driven(&args, "MaximumIndependentSet", &resolved_variant) .expect("schema-driven create should parse"); diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 1be8432da..34cbbbc66 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -9,16 +9,61 @@ use std::any::Any; use std::collections::BTreeMap; use std::path::Path; -pub fn list(out: &OutputConfig) -> Result<()> { +pub fn list( + query: Option<&str>, + category: Option<&str>, + all: bool, + verbose: bool, + out: &OutputConfig, +) -> Result<()> { use crate::output::{format_table, Align}; let graph = ReductionGraph::new(); - let mut types = graph.problem_types(); - types.sort(); + let catalog = problemreductions::registry::problem_types(); + let mut variant_aliases = BTreeMap::<&str, Vec<&str>>::new(); + for entry in problemreductions::registry::variant_entries() { + variant_aliases + .entry(entry.name) + .or_default() + .extend(entry.aliases); + } + let query = query.map(str::to_lowercase); + let category = category.map(str::to_lowercase); + let selected = catalog + .iter() + .filter(|problem| { + category.as_ref().is_none_or(|wanted| { + problem + .category + .unwrap_or("uncategorized") + .eq_ignore_ascii_case(wanted) + }) && query.as_ref().is_none_or(|needle| { + problem.canonical_name.to_lowercase().contains(needle) + || problem.display_name.to_lowercase().contains(needle) + || problem + .aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + || variant_aliases + .get(problem.canonical_name) + .is_some_and(|aliases| { + aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + }) + }) + }) + .collect::>(); + let selected_names = selected + .iter() + .map(|problem| problem.canonical_name) + .collect::>(); + let needs_all_variant_rows = out.json || out.output.is_some(); // Collect data: one row per variant, grouped by problem type. struct VariantRow { + problem: &'static str, /// Full problem/variant name (e.g., "MIS/SimpleGraph/i32") display: String, /// Aliases (shown only on first variant of each problem) @@ -32,7 +77,11 @@ pub fn list(out: &OutputConfig) -> Result<()> { } let mut rows_data: Vec = Vec::new(); - for name in &types { + for problem in &catalog { + let name = problem.canonical_name; + if !needs_all_variant_rows && (!verbose || !selected_names.contains(name)) { + continue; + } let variants = graph.variants_for(name); let default_variant = graph.default_variant_for(name); let problem_aliases = aliases_for(name); @@ -68,6 +117,7 @@ pub fn list(out: &OutputConfig) -> Result<()> { } rows_data.push(VariantRow { + problem: name, display, aliases: parts.join(", "), is_default, @@ -77,12 +127,12 @@ pub fn list(out: &OutputConfig) -> Result<()> { } } - let summary = format!( - "Registered problems: {} types, {} reductions, {} variant nodes\n", - graph.num_types(), - graph.num_reductions(), - graph.num_variant_nodes(), - ); + let mut category_counts = BTreeMap::new(); + for problem in &catalog { + *category_counts + .entry(problem.category.unwrap_or("uncategorized")) + .or_insert(0usize) += 1; + } let columns: Vec<(&str, Align, usize)> = vec![ ("Problem", Align::Left, 7), @@ -91,7 +141,11 @@ pub fn list(out: &OutputConfig) -> Result<()> { ("Complexity", Align::Left, 10), ]; - let rows: Vec> = rows_data + let visible_rows = rows_data + .iter() + .filter(|row| selected_names.contains(row.problem)) + .collect::>(); + let rows: Vec> = visible_rows .iter() .map(|r| { let label = if r.is_default { @@ -115,12 +169,71 @@ pub fn list(out: &OutputConfig) -> Result<()> { let color_fns: Vec> = vec![Some(crate::output::fmt_problem_name), None, None, None]; - let mut text = String::new(); - text.push_str(&crate::output::fmt_section(&summary)); - text.push('\n'); - text.push_str(&format_table(&columns, &rows, &color_fns)); - text.push_str("\n* = default variant\n"); - text.push_str("Use `pred show ` to see reductions and fields.\n"); + let expanded = all || query.is_some() || category.is_some() || verbose; + let mut text = format!( + "{}\n\n", + crate::output::fmt_section(&format!( + "Registered catalog: {} problem types, {} variant nodes, {} reduction rules", + graph.num_types(), + graph.num_variant_nodes(), + graph.num_reductions(), + )) + ); + if expanded { + if selected.is_empty() { + text.push_str("No matching problem types.\n"); + } else if verbose { + text.push_str(&format_table(&columns, &rows, &color_fns)); + text.push_str("\n* = default variant\n"); + } else { + let compact_rows = selected + .iter() + .map(|problem| { + let mut aliases = problem.aliases.to_vec(); + if let Some(extra) = variant_aliases.get(problem.canonical_name) { + for alias in extra { + if !aliases + .iter() + .any(|known| known.eq_ignore_ascii_case(alias)) + { + aliases.push(alias); + } + } + } + vec![ + problem.canonical_name.to_string(), + aliases.join(", "), + problem.category.unwrap_or("uncategorized").to_string(), + graph.variants_for(problem.canonical_name).len().to_string(), + ] + }) + .collect::>(); + text.push_str(&format_table( + &[ + ("Problem", Align::Left, 7), + ("Aliases", Align::Left, 7), + ("Category", Align::Left, 8), + ("Variants", Align::Right, 8), + ], + &compact_rows, + &[Some(crate::output::fmt_problem_name), None, None, None], + )); + } + text.push_str("\nUse `pred show ` for fields, variants, and reductions.\n"); + } else { + let category_rows = category_counts + .iter() + .map(|(name, count)| vec![name.to_string(), count.to_string()]) + .collect::>(); + text.push_str(&format_table( + &[("Category", Align::Left, 8), ("Problems", Align::Right, 8)], + &category_rows, + &[None, None], + )); + text.push_str( + "\nSearch with `pred list `, browse a category with `pred list --category `, or use `pred list --all`.\n", + ); + } let json = serde_json::json!({ "num_types": graph.num_types(), @@ -140,7 +253,7 @@ pub fn list(out: &OutputConfig) -> Result<()> { out.emit_with_default_name("pred_graph_list.json", &text, &json) } -pub fn list_rules(out: &OutputConfig) -> Result<()> { +pub fn list_rules(query: Option<&str>, all: bool, verbose: bool, out: &OutputConfig) -> Result<()> { use crate::output::{format_table, Align}; let graph = ReductionGraph::new(); @@ -168,7 +281,46 @@ pub fn list_rules(out: &OutputConfig) -> Result<()> { } } - let summary = format!("Registered reduction rules: {}\n", rows_data.len()); + let query = query.map(str::to_lowercase); + let alias_matches = query + .as_ref() + .map(|needle| { + problemreductions::registry::variant_entries() + .into_iter() + .filter(|entry| { + entry + .aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + }) + .map(|entry| entry.name.to_lowercase()) + .chain( + problemreductions::registry::problem_types() + .into_iter() + .filter(|problem| { + problem + .aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + }) + .map(|problem| problem.canonical_name.to_lowercase()), + ) + .collect::>() + }) + .unwrap_or_default(); + let selected = rows_data + .iter() + .filter(|row| { + query.as_ref().is_none_or(|needle| { + row.source.to_lowercase().contains(needle) + || row.target.to_lowercase().contains(needle) + || alias_matches.iter().any(|name| { + row.source.to_lowercase().contains(name) + || row.target.to_lowercase().contains(name) + }) + }) + }) + .collect::>(); let columns: Vec<(&str, Align, usize)> = vec![ ("Source", Align::Left, 6), @@ -176,9 +328,15 @@ pub fn list_rules(out: &OutputConfig) -> Result<()> { ("Size change", Align::Left, 8), ]; - let rows: Vec> = rows_data + let rows: Vec> = selected .iter() - .map(|r| vec![r.source.clone(), r.target.clone(), r.size_contract.clone()]) + .map(|r| { + let mut row = vec![r.source.clone(), r.target.clone()]; + if verbose { + row.push(r.size_contract.clone()); + } + row + }) .collect(); let color_fns: Vec> = vec![ @@ -187,11 +345,33 @@ pub fn list_rules(out: &OutputConfig) -> Result<()> { None, ]; - let mut text = String::new(); - text.push_str(&crate::output::fmt_section(&summary)); - text.push('\n'); - text.push_str(&format_table(&columns, &rows, &color_fns)); - text.push_str("\nUse `pred show ` for details on a specific problem.\n"); + let expanded = all || query.is_some() || verbose; + let mut text = format!( + "{}\n", + crate::output::fmt_section(&format!("Registered reduction rules: {}", rows_data.len())) + ); + if expanded { + let compact_columns = if verbose { + columns + } else { + vec![("Source", Align::Left, 6), ("Target", Align::Left, 6)] + }; + let compact_colors: Vec> = if verbose { + color_fns + } else { + vec![ + Some(crate::output::fmt_problem_name), + Some(crate::output::fmt_problem_name), + ] + }; + text.push('\n'); + text.push_str(&format_table(&compact_columns, &rows, &compact_colors)); + text.push_str("\nUse `pred show ` for details on a specific problem.\n"); + } else { + text.push_str( + "\nSearch with `pred list --rules ` or use `pred list --rules --all`. Add `--verbose` for size contracts.\n", + ); + } let json = serde_json::json!({ "num_rules": rows_data.len(), diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index dd6f3242e..6cb313b29 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -50,11 +50,17 @@ fn main() -> anyhow::Result<()> { }; match cli.command { - Commands::List { rules } => { + Commands::List { + query, + rules, + category, + all, + verbose, + } => { if rules { - commands::graph::list_rules(&out) + commands::graph::list_rules(query.as_deref(), all, verbose, &out) } else { - commands::graph::list(&out) + commands::graph::list(query.as_deref(), category.as_deref(), all, verbose, &out) } } Commands::Show { problem } => commands::graph::show(&problem, &out), diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 2e5cbd1a2..f51d70705 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1,17 +1,8 @@ -use crate::util; -use problemreductions::models::graph::{ - KClique, LongestCircuit, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinimumDominatingSet, MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman, -}; use problemreductions::registry::collect_schemas; use problemreductions::rules::{ReductionGraph, TraversalFlow}; use problemreductions::solvers::SolverRequest; -use problemreductions::topology::{ - Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, -}; use rmcp::handler::server::wrapper::Parameters; use rmcp::tool; -use serde::Serialize; use std::collections::BTreeMap; use crate::dispatch::{ @@ -308,7 +299,7 @@ impl McpServer { .unwrap_or(false); if is_random { - return self.create_random_inner(&canonical, &resolved_variant, params); + return self.generate_registered_random_inner(&canonical, &resolved_variant, params); } let normalized = normalize_mcp_create_inputs(params)?; @@ -323,185 +314,26 @@ impl McpServer { Ok(serde_json::to_string_pretty(&output)?) } - fn create_random_inner( + fn generate_registered_random_inner( &self, canonical: &str, resolved_variant: &BTreeMap, params: &serde_json::Value, ) -> anyhow::Result { - let num_vertices = params - .get("num_vertices") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .ok_or_else(|| { - anyhow::anyhow!("Random generation requires 'num_vertices' parameter") - })?; - let seed = params.get("seed").and_then(|v| v.as_u64()); - let graph_type = resolved_variant - .get("graph") - .map(|s| s.as_str()) - .unwrap_or("SimpleGraph"); - - let (data, variant) = match canonical { - "MaximumIndependentSet" - | "MinimumVertexCover" - | "MaximumClique" - | "MinimumDominatingSet" => { - let weights = vec![1i32; num_vertices]; - match graph_type { - "KingsSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, seed); - let graph = KingsSubgraph::new(positions); - ( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "TriangularSubgraph" => { - let positions = util::create_random_int_positions(num_vertices, seed); - let graph = TriangularSubgraph::new(positions); - ( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - "UnitDiskGraph" => { - let radius = params.get("radius").and_then(|v| v.as_f64()).unwrap_or(1.0); - let positions = util::create_random_float_positions(num_vertices, seed); - let graph = UnitDiskGraph::new(positions, radius); - ( - ser_vertex_weight_problem_generic(canonical, graph, weights)?, - resolved_variant.clone(), - ) - } - _ => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - ser_vertex_weight_problem(canonical, graph, weights)? - } - } - } - "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let num_edges = graph.num_edges(); - let edge_weights = vec![1i32; num_edges]; - ser_edge_weight_problem(canonical, graph, edge_weights)? - } - "LongestCircuit" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let edge_lengths = vec![1i32; graph.num_edges()]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - (ser(LongestCircuit::new(graph, edge_lengths))?, variant) - } - "SpinGlass" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let num_edges = graph.num_edges(); - let couplings = vec![1i32; num_edges]; - let fields = vec![0i32; num_vertices]; - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - variant, - ) - } - "KColoring" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); - let (k, _variant) = - util::validate_k_param(resolved_variant, k_flag, Some(3), "KColoring")?; - util::ser_kcoloring(graph, k)? - } - "KClique" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); - let k = parse_kclique_threshold(k_flag, graph.num_vertices())?; - ( - ser(KClique::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - "MinimumSumMulticenter" => { - let edge_prob = params - .get("edge_prob") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - anyhow::bail!("edge_prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, seed); - let num_edges = graph.num_edges(); - let vertex_weights = vec![1i32; num_vertices]; - let edge_lengths = vec![1i32; num_edges]; - let k = params - .get("k") - .and_then(|v| v.as_u64()) - .map(|v| v as usize) - .unwrap_or(1.max(num_vertices / 3)); - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(MinimumSumMulticenter::new( - graph, - vertex_weights, - edge_lengths, - k, - ))?, - variant, - ) - } - _ => anyhow::bail!( - "Random generation is not supported for {}. \ - Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, \ - TravelingSalesman, LongestCircuit, MinimumSumMulticenter)", - canonical - ), - }; - + let mut inputs = params + .as_object() + .ok_or_else(|| anyhow::anyhow!("random inputs must be a JSON object"))? + .clone(); + inputs.remove("random"); + let problem = problemreductions::registry::generate_random_dyn( + canonical, + resolved_variant, + serde_json::Value::Object(inputs), + )?; let output = ProblemJsonOutput { - problem_type: canonical.to_string(), - variant, - data, + problem_type: problem.problem_name().to_string(), + variant: problem.variant_map(), + data: problem.serialize_json(), }; Ok(serde_json::to_string_pretty(&output)?) } @@ -826,73 +658,6 @@ fn normalize_mcp_create_inputs(params: &serde_json::Value) -> anyhow::Result(problem: T) -> anyhow::Result { - util::ser(problem) -} - -fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { - util::variant_map(pairs) -} - -/// Serialize a vertex-weight graph problem (MIS, MVC, MaxClique, MinDomSet). -fn ser_vertex_weight_problem( - canonical: &str, - graph: SimpleGraph, - weights: Vec, -) -> anyhow::Result<(serde_json::Value, BTreeMap)> { - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?, - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?, - "MaximumClique" => ser(MaximumClique::new(graph, weights))?, - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?, - _ => unreachable!(), - }; - Ok((data, variant)) -} - -/// Serialize an edge-weight graph problem (MaxCut, MaximumMatching, TravelingSalesman). -fn ser_edge_weight_problem( - canonical: &str, - graph: SimpleGraph, - edge_weights: Vec, -) -> anyhow::Result<(serde_json::Value, BTreeMap)> { - let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - let data = match canonical { - "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, - "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, - "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, - _ => unreachable!(), - }; - Ok((data, variant)) -} - -/// Serialize a vertex-weight problem with a generic graph type. -fn ser_vertex_weight_problem_generic( - canonical: &str, - graph: G, - weights: Vec, -) -> anyhow::Result { - match canonical { - "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights)), - "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights)), - "MaximumClique" => ser(MaximumClique::new(graph, weights)), - "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights)), - _ => unreachable!(), - } -} - -fn parse_kclique_threshold(k_flag: Option, num_vertices: usize) -> anyhow::Result { - let k = k_flag.ok_or_else(|| anyhow::anyhow!("KClique requires 'k'"))?; - if k == 0 { - anyhow::bail!("KClique: 'k' must be positive"); - } - if k > num_vertices { - anyhow::bail!("KClique: k must be <= graph num_vertices"); - } - Ok(k) -} - /// Solve a plain problem and return JSON string. fn solve_problem_inner( problem_type: &str, @@ -1036,4 +801,39 @@ mod tests { "construction inputs must be a JSON object" ); } + + #[test] + fn random_contract_mcp_uses_the_selected_variant_generator() { + let output = McpServer::new() + .create_problem_inner( + "MaximumIndependentSet", + &serde_json::json!({"random": true, "num_vertices": 4, "seed": 7}), + ) + .unwrap(); + + let created: ProblemJsonOutput = serde_json::from_str(&output).unwrap(); + assert_eq!(created.variant["graph"], "SimpleGraph"); + assert_eq!(created.variant["weight"], "One"); + assert_eq!(created.data["graph"]["num_vertices"], 4); + } + + #[test] + fn random_contract_mcp_rejects_inputs_outside_model_contract() { + let error = McpServer::new() + .create_problem_inner( + "MaximumIndependentSet", + &serde_json::json!({ + "random": true, + "num_vertices": 4, + "bound": 2, + }), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unknown construction input(s): bound"), + "unexpected error: {error}" + ); + } } diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 1d08f642f..cbd8a745a 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -160,6 +160,8 @@ problemreductions::inventory::submit! { .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; Ok(Box::new(problem)) }, + random_inputs: None, + random_fn: None, factory: |data| { let problem: AggregateValueSource = serde_json::from_value(data)?; Ok(Box::new(problem)) @@ -187,6 +189,8 @@ problemreductions::inventory::submit! { .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; Ok(Box::new(problem)) }, + random_inputs: None, + random_fn: None, factory: |data| { let problem: AggregateValueTarget = serde_json::from_value(data)?; Ok(Box::new(problem)) diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index d33edea1b..04204bb8b 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -2,104 +2,6 @@ use anyhow::{bail, Result}; use num_bigint::BigUint; -use problemreductions::prelude::*; -use problemreductions::topology::SimpleGraph; -use problemreductions::variant::{K2, K3, KN}; -use serde::Serialize; -use std::collections::BTreeMap; - -// --------------------------------------------------------------------------- -// K-parameter validation -// --------------------------------------------------------------------------- - -/// Derive the k variant string from a numeric k value. -fn k_variant_str(k: usize) -> &'static str { - match k { - 1 => "K1", - 2 => "K2", - 3 => "K3", - 4 => "K4", - 5 => "K5", - _ => "KN", - } -} - -/// Validate that `--k` (or `params.k`) is consistent with a variant suffix -/// (e.g., `/K2`). Returns the effective k value and variant map. -/// -/// Rules: -/// - If the resolved variant has a specific k (e.g., K2), `k_flag` must -/// either be `None` or match. A mismatch is an error. -/// - If the resolved variant has k=KN (or no k), any `k_flag` is accepted. -/// - If `k_flag` is `None`, k is inferred from the variant (K2→2, K3→3, etc.), -/// or defaults to `default_k`. -pub fn validate_k_param( - resolved_variant: &BTreeMap, - k_flag: Option, - default_k: Option, - problem_name: &str, -) -> Result<(usize, BTreeMap)> { - let variant_k_str = resolved_variant.get("k").map(|s| s.as_str()); - let variant_k_num: Option = match variant_k_str { - Some("K1") => Some(1), - Some("K2") => Some(2), - Some("K3") => Some(3), - Some("K4") => Some(4), - Some("K5") => Some(5), - _ => None, // KN or absent - }; - - let effective_k = match (k_flag, variant_k_num) { - (Some(flag), Some(from_variant)) if flag != from_variant => { - bail!( - "{problem_name}: --k {flag} conflicts with variant /{} (k={from_variant}). \ - Either omit the suffix or match the --k value.", - variant_k_str.unwrap() - ); - } - (Some(flag), _) => flag, - (None, Some(from_variant)) => from_variant, - (None, None) => match default_k { - Some(d) => d, - None => bail!("{problem_name} requires --k "), - }, - }; - - if effective_k == 0 { - bail!("{problem_name}: --k must be positive"); - } - - // Build the variant map with the effective k - let mut variant = resolved_variant.clone(); - variant.insert("k".to_string(), k_variant_str(effective_k).to_string()); - - Ok((effective_k, variant)) -} - -// --------------------------------------------------------------------------- -// K-problem serialization -// --------------------------------------------------------------------------- - -/// Serialize a KColoring instance given a graph and validated k. -pub fn ser_kcoloring( - graph: SimpleGraph, - k: usize, -) -> Result<(serde_json::Value, BTreeMap)> { - match k { - 2 => Ok(( - ser(KColoring::::new(graph))?, - variant_map(&[("k", "K2"), ("graph", "SimpleGraph")]), - )), - 3 => Ok(( - ser(KColoring::::new(graph))?, - variant_map(&[("k", "K3"), ("graph", "SimpleGraph")]), - )), - _ => Ok(( - ser(KColoring::::with_k(graph, k))?, - variant_map(&[("k", "KN"), ("graph", "SimpleGraph")]), - )), - } -} // --------------------------------------------------------------------------- // Parsing helpers @@ -134,97 +36,6 @@ where } // --------------------------------------------------------------------------- -// Random generation (LCG-based) -// --------------------------------------------------------------------------- - -/// LCG PRNG step — returns next state and a uniform f64 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 seed or system time. -pub fn lcg_init(seed: Option) -> u64 { - seed.unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() as u64 - }) -} - -/// Generate a random Erdos-Renyi graph using a simple LCG PRNG. -pub fn create_random_graph(num_vertices: usize, edge_prob: f64, seed: Option) -> SimpleGraph { - let mut state = lcg_init(seed); - let mut edges = Vec::new(); - for i in 0..num_vertices { - for j in (i + 1)..num_vertices { - let rand_val = lcg_step(&mut state); - if rand_val < edge_prob { - edges.push((i, j)); - } - } - } - SimpleGraph::new(num_vertices, edges) -} - -/// Generate random unique integer positions on a grid for KingsSubgraph/TriangularSubgraph. -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 mut positions = std::collections::BTreeSet::new(); - while positions.len() < num_vertices { - let x = (lcg_step(&mut state) * grid_size as f64) as i32; - let y = (lcg_step(&mut state) * grid_size as f64) as i32; - positions.insert((x, y)); - } - positions.into_iter().collect() -} - -/// Generate random float positions in [0, sqrt(N)] x [0, sqrt(N)] for UnitDiskGraph. -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(|_| { - let x = lcg_step(&mut state) * side; - let y = lcg_step(&mut state) * side; - (x, y) - }) - .collect() -} - -/// Choose `k` distinct elements from `0..n` using Fisher-Yates partial shuffle. -/// Returns a sorted vector of chosen indices. -pub fn lcg_choose(state: &mut u64, n: usize, k: usize) -> Vec { - assert!(k <= n, "k={k} exceeds n={n}"); - let mut indices: Vec = (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: Vec = indices[..k].to_vec(); - chosen.sort_unstable(); - chosen -} - -// --------------------------------------------------------------------------- -// Small shared helpers -// --------------------------------------------------------------------------- - -pub fn ser(problem: T) -> Result { - Ok(serde_json::to_value(problem)?) -} - -pub fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() -} - /// Parse a comma-separated list of values. pub fn parse_comma_list(s: &str) -> Result> where @@ -264,19 +75,3 @@ pub fn parse_edge_pairs(s: &str) -> Result> { }) .collect() } - -#[cfg(test)] -mod tests { - use super::validate_k_param; - use std::collections::BTreeMap; - - #[test] - fn test_validate_k_param_rejects_zero() { - let err = validate_k_param(&BTreeMap::new(), Some(0), None, "KthBestSpanningTree") - .expect_err("k=0 should be rejected before problem construction"); - assert!( - err.to_string().contains("positive"), - "unexpected error message: {err}" - ); - } -} diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index cb9a5c467..0ea1d9d77 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -86,13 +86,39 @@ fn test_list() { let output = pred().args(["list"]).output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("MaximumIndependentSet")); - assert!(stdout.contains("QUBO")); + assert!(stdout.contains("Registered catalog")); + assert!(stdout.contains("graph")); + assert!(!stdout.contains("MaximumIndependentSet")); + assert!(stdout.lines().count() < 30, "default list is too verbose"); +} + +#[test] +fn test_list_filters_by_category() { + let output = pred() + .args(["list", "--category", "formula"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("KSatisfiability")); + assert!(!stdout.contains("MaximumIndependentSet")); +} + +#[test] +fn test_list_searches_variant_aliases() { + let output = pred().args(["list", "3SAT"]).output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("KSatisfiability")); + assert!(stdout.contains("3SAT")); } #[test] fn test_list_includes_undirected_two_commodity_integral_flow() { - let output = pred().args(["list"]).output().unwrap(); + let output = pred() + .args(["list", "UndirectedTwoCommodity"]) + .output() + .unwrap(); assert!( output.status.success(), "stderr: {}", @@ -104,7 +130,10 @@ fn test_list_includes_undirected_two_commodity_integral_flow() { #[test] fn test_list_includes_integral_flow_homologous_arcs() { - let output = pred().args(["list"]).output().unwrap(); + let output = pred() + .args(["list", "IntegralFlowHomologousArcs"]) + .output() + .unwrap(); assert!( output.status.success(), "stderr: {}", @@ -128,7 +157,10 @@ fn test_solve_help_mentions_string_to_string_correction_bruteforce() { #[test] fn test_list_rules() { - let output = pred().args(["list", "--rules"]).output().unwrap(); + let output = pred() + .args(["list", "--rules", "--all", "--verbose"]) + .output() + .unwrap(); assert!( output.status.success(), "stderr: {}", @@ -160,6 +192,14 @@ fn test_list_rules_json() { assert!(rules[0]["size_contract"].is_string()); } +#[test] +fn test_list_rules_searches_problem_aliases() { + let output = pred().args(["list", "--rules", "3SAT"]).output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("KSatisfiability")); +} + #[test] fn test_show() { let output = pred().args(["show", "MIS"]).output().unwrap(); @@ -6811,7 +6851,7 @@ fn test_create_random_steiner_tree_requires_two_vertices() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("SteinerTree random generation requires --num-vertices >= 2"), + stderr.contains("num_vertices must be at least 2"), "{stderr}" ); } @@ -6833,7 +6873,7 @@ fn test_create_random_invalid_edge_prob() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--edge-prob must be between"), + stderr.contains("edge_prob must be between"), "expected edge-prob validation error, got: {stderr}" ); } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index fd71764ce..b63ab6c71 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -621,6 +621,7 @@ struct DeclareVariantEntry { complexity: syn::LitStr, aliases: Vec, create_spec: Option, + random: bool, } impl syn::parse::Parse for DeclareVariantsInput { @@ -639,6 +640,7 @@ impl syn::parse::Parse for DeclareVariantsInput { let mut aliases = Vec::new(); let mut create_spec = None; + let mut random = false; while input.peek(syn::Ident) { let ident: syn::Ident = input.parse()?; if ident == "aliases" { @@ -662,10 +664,15 @@ impl syn::parse::Parse for DeclareVariantsInput { return Err(syn::Error::new(ident.span(), "duplicate `create` clause")); } create_spec = Some(input.parse()?); + } else if ident == "random" { + if random { + return Err(syn::Error::new(ident.span(), "duplicate `random` clause")); + } + random = true; } else { return Err(syn::Error::new( ident.span(), - format!("expected `aliases` or `create`, found `{ident}`"), + format!("expected `aliases`, `create`, or `random`, found `{ident}`"), )); } } @@ -676,6 +683,7 @@ impl syn::parse::Parse for DeclareVariantsInput { complexity, aliases, create_spec, + random, }); if input.peek(syn::Token![,]) { @@ -759,6 +767,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result = entry.aliases.iter().map(|s| s.value()).collect(); @@ -836,8 +845,23 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result::INPUTS) }, + quote! { + Some(|data: serde_json::Value| -> Result, crate::registry::ConstructionError> { + Ok(Box::new(<#ty as crate::registry::RandomGenerate>::generate(data)?)) + }) + }, + ) + } else { + (quote! { None }, quote! { None }) + }; + let dispatch_fields = quote! { #construction_fields + random_inputs: #random_inputs, + random_fn: #random_fn, factory: |data: serde_json::Value| -> Result, serde_json::Error> { let p: #ty = serde_json::from_value(data)?; Ok(Box::new(p)) @@ -1053,7 +1077,7 @@ mod tests { }; assert_eq!( err.to_string(), - "expected `aliases` or `create`, found `nicknames`" + "expected `aliases`, `create`, or `random`, found `nicknames`" ); } diff --git a/src/lib.rs b/src/lib.rs index d590510b7..31956e1ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod expr; pub mod growth; pub mod io; pub mod models; +pub mod random; pub mod registry; pub mod rules; pub mod size_bound; diff --git a/src/models/decision.rs b/src/models/decision.rs index 3f26353cc..9715725b9 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -45,6 +45,7 @@ macro_rules! register_decision_variant { dims: [$($dim:expr),* $(,)?], fields: [$($field:expr),* $(,)?], size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] + $(, random: $random:ty)? ) => { impl $crate::registry::CreateSpec for $crate::models::decision::DecisionCreateSpec<$inner> @@ -55,9 +56,7 @@ macro_rules! register_decision_variant { ]; } - $crate::declare_variants! { - default $crate::models::decision::Decision<$inner> => $complexity create $crate::models::decision::DecisionCreateSpec<$inner>, - } + $crate::register_decision_variant!(@declare $inner, $complexity $(, $random)?); $crate::inventory::submit! { $crate::registry::ProblemSchemaEntry { @@ -140,6 +139,17 @@ macro_rules! register_decision_variant { } } }; + + (@declare $inner:ty, $complexity:literal, $random:ty) => { + $crate::declare_variants! { + default $crate::models::decision::Decision<$inner> => $complexity create $crate::models::decision::DecisionCreateSpec<$inner> random, + } + }; + (@declare $inner:ty, $complexity:literal) => { + $crate::declare_variants! { + default $crate::models::decision::Decision<$inner> => $complexity create $crate::models::decision::DecisionCreateSpec<$inner>, + } + }; (@display_name "DecisionMinimumVertexCover") => { "Decision Minimum Vertex Cover" }; diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 1dfbd5824..030f55f3c 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -209,8 +209,18 @@ pub(crate) fn canonical_model_example_specs() -> Vec "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec, + default BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index 156bf995a..06f9d7914 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -293,8 +293,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" create GeneralizedHexCreateSpec, + default GeneralizedHex => "3^num_playable_vertices" create GeneralizedHexCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/hamiltonian_circuit.rs b/src/models/graph/hamiltonian_circuit.rs index 471c15af0..a66d85684 100644 --- a/src/models/graph/hamiltonian_circuit.rs +++ b/src/models/graph/hamiltonian_circuit.rs @@ -164,8 +164,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..a50787e0b 100644 --- a/src/models/graph/hamiltonian_path.rs +++ b/src/models/graph/hamiltonian_path.rs @@ -167,8 +167,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..a11c6fdb9 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -75,6 +75,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 +243,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/kclique.rs b/src/models/graph/kclique.rs index 71c8d2e78..5f3618a72 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -176,8 +176,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" create KCliqueCreateSpec, + 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 cbb91175a..9dfa3629f 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -273,13 +273,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" create RuntimeKColoringCreateSpec, - KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec, - KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec, - KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec, + 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" create FixedKColoringCreateSpec, + KColoring => "2^num_vertices" create FixedKColoringCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 0fd0afd71..4cde1aa97 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -57,6 +57,22 @@ struct LengthBoundedDisjointPathsCreateSpec { 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; @@ -360,8 +376,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)" create LengthBoundedDisjointPathsCreateSpec, + 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 1c9988599..088070332 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -311,8 +311,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" create LongestCircuitCreateSpec, + default LongestCircuit => "2^num_vertices * num_vertices^2" create LongestCircuitCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index edd4354ee..cdba9c762 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -266,8 +266,14 @@ 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)" create MaxCutI32CreateSpec, + default MaxCut => "2^(2.372 * num_vertices / 3)" create MaxCutI32CreateSpec random, MaxCut => "2^(0.7907 * num_vertices)" create MaxCutOneCreateSpec, } diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index f157fac2c..9caaa80c3 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -241,8 +241,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)" create MaximalISCreateSpec, + 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..57d08f850 100644 --- a/src/models/graph/maximum_achromatic_number.rs +++ b/src/models/graph/maximum_achromatic_number.rs @@ -155,8 +155,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 a507783ac..00eba9ba5 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -183,9 +183,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" create MaximumCliqueCreateSpec, - default MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec, + 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_domatic_number.rs b/src/models/graph/maximum_domatic_number.rs index 1052f6163..518ebafff 100644 --- a/src/models/graph/maximum_domatic_number.rs +++ b/src/models/graph/maximum_domatic_number.rs @@ -154,8 +154,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_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 9bf1b69cb..1d3eb5888 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -282,14 +282,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" create MaximumIndependentSetSimpleI32CreateSpec, - default MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleOneCreateSpec, - MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsI32CreateSpec, - MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsOneCreateSpec, - MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetTriangularI32CreateSpec, - MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskI32CreateSpec, - MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskOneCreateSpec, + 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..3feb0c489 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -163,8 +163,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 d648fe2ac..73bb86524 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -266,8 +266,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" create MaximumMatchingCreateSpec, + default MaximumMatching => "num_vertices^3" create MaximumMatchingCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 55080af3c..05be374ba 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -153,8 +153,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 36e986e31..855302fae 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -259,8 +259,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" create MinimumCutIntoBoundedSetsCreateSpec, + 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 a780fdd7b..85019a770 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -185,9 +185,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" create MinimumDominatingSetCreateSpec, - MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec, + default MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, + MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet @@ -274,6 +281,8 @@ inventory::submit! { .map(|problem| Box::new(problem) as Box) .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string())) }, + random_inputs: None, + random_fn: None, factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) diff --git a/src/models/graph/minimum_intersection_graph_basis.rs b/src/models/graph/minimum_intersection_graph_basis.rs index d78795962..19a894009 100644 --- a/src/models/graph/minimum_intersection_graph_basis.rs +++ b/src/models/graph/minimum_intersection_graph_basis.rs @@ -156,8 +156,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..3b4c53d5c 100644 --- a/src/models/graph/minimum_maximal_matching.rs +++ b/src/models/graph/minimum_maximal_matching.rs @@ -145,8 +145,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_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 34d652945..c2f9bba6d 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -77,6 +77,18 @@ struct MinimumSumMulticenterCreateSpec { 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; @@ -312,8 +324,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" create MinimumSumMulticenterCreateSpec, + 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 e219c2d72..6dcadd879 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -175,9 +175,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" create MinimumVertexCoverCreateSpec, - MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec, + default MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, + MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover @@ -206,6 +213,38 @@ 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", @@ -219,9 +258,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: DecisionMinimumVertexCoverRandomSpec ); #[cfg(feature = "example-db")] diff --git a/src/models/graph/optimal_linear_arrangement.rs b/src/models/graph/optimal_linear_arrangement.rs index d2f14f2f7..829fb046d 100644 --- a/src/models/graph/optimal_linear_arrangement.rs +++ b/src/models/graph/optimal_linear_arrangement.rs @@ -153,8 +153,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 diff --git a/src/models/graph/rooted_tree_arrangement.rs b/src/models/graph/rooted_tree_arrangement.rs index 6fc20b366..d5ac3e9f0 100644 --- a/src/models/graph/rooted_tree_arrangement.rs +++ b/src/models/graph/rooted_tree_arrangement.rs @@ -34,6 +34,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 +216,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/spin_glass.rs b/src/models/graph/spin_glass.rs index a3f51e55b..bdc8e4a62 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -298,8 +298,14 @@ 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" create SpinGlassI32CreateSpec, + default SpinGlass => "2^num_spins" create SpinGlassI32CreateSpec random, SpinGlass => "2^num_spins" create SpinGlassF64CreateSpec, } diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 83436d678..3b2a49bf1 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -286,8 +286,24 @@ 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" create SteinerTreeCreateSpec, + 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, } diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs index 4fbc64a2c..e176e1d12 100644 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -305,8 +305,18 @@ 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" create SteinerTreeInGraphsCreateSpec, + default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec random, SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, } diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index fdd71417e..efbc98802 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -312,8 +312,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" create TravelingSalesmanCreateSpec, + default TravelingSalesman => "2^num_vertices" create TravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 000000000..81f4a07b9 --- /dev/null +++ b/src/random.rs @@ -0,0 +1,239 @@ +//! 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 mut positions = std::collections::BTreeSet::new(); + while positions.len() < num_vertices { + positions.insert(( + (lcg_step(&mut state) * grid_size as f64) as i32, + (lcg_step(&mut state) * grid_size as f64) as i32, + )); + } + positions.into_iter().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/mod.rs b/src/registry/mod.rs index 5e91ffa07..893b850d3 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -62,7 +62,7 @@ pub use schema::{ pub use variant::{ find_variant_by_alias, find_variant_entry, validate_create_inputs, validate_direct_create_inputs, validate_variant_aliases, variant_entries, ConstructProblemFn, - ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, VariantEntry, + ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, RandomGenerate, VariantEntry, }; /// Construct a problem from normalized construction inputs using the exact @@ -81,6 +81,27 @@ pub fn construct_dyn( (entry.construct_fn)(data) } +/// Generate a problem using the exact variant's model-owned random generator. +pub fn generate_random_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(), + } + })?; + let generate = entry.random_fn.ok_or_else(|| { + ConstructionError::Conversion(format!( + "random generation is not registered for `{}`", + crate::registry::variant::variant_label(entry) + )) + })?; + generate(data) +} + use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 20d86fbb5..43b3f794e 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -19,6 +19,8 @@ pub struct ProblemType { pub description: &'static str, /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], + /// Top-level model category derived from the declaring module path. + pub category: Option<&'static str>, } impl ProblemType { @@ -31,6 +33,11 @@ impl ProblemType { dimensions: entry.dimensions, description: entry.description, fields: entry.fields, + category: entry + .module_path + .split("::models::") + .nth(1) + .and_then(|path| path.split("::").next()), } } diff --git a/src/registry/variant.rs b/src/registry/variant.rs index ef5299e5f..bd8a6552e 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -116,6 +116,15 @@ pub enum ConstructionError { pub type ConstructProblemFn = fn(serde_json::Value) -> Result, ConstructionError>; +/// 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], @@ -199,6 +208,10 @@ pub struct VariantEntry { pub create_inputs: Option<&'static [CreateInputInfo]>, /// Construct a validated concrete problem from normalized construction data. pub construct_fn: ConstructProblemFn, + /// Model-owned random generation inputs, when this variant supports generation. + pub random_inputs: Option<&'static [CreateInputInfo]>, + /// Generate a concrete random problem for this exact variant. + pub random_fn: 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. diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index ec36272f4..abbfc479e 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -1,5 +1,6 @@ use crate::registry::variant::{ - validate_create_inputs, validate_direct_create_inputs, validate_variant_aliases, variant_label, + 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}; @@ -312,3 +313,58 @@ fn variant_label_with_variant_dimensions() { "expected label to include k=K3, got: {label}" ); } + +#[test] +fn random_contract_metadata_and_generator_are_registered_together() { + let entries = variant_entries(); + assert!(entries.iter().any(|entry| entry.random_fn.is_some())); + + for entry in entries { + assert_eq!( + entry.random_inputs.is_some(), + entry.random_fn.is_some(), + "{} has a partial random-generation registration", + variant_label(entry) + ); + let Some(inputs) = entry.random_inputs else { + continue; + }; + let mut names = BTreeSet::new(); + for input in 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_fn.is_some()) + .map(|entry| entry.name) + .collect::>(); + + for name in expected.split_whitespace() { + assert!(registered.contains(name), "{name} lost random generation"); + } +} From 105993d47ebb5bc00843a52bffe680c71ef57ab9 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 12 Aug 2026 16:20:47 +0800 Subject: [PATCH 4/7] refactor: simplify registry-driven create --- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 8 +- .../src/commands/create/schema_support.rs | 11 +- problemreductions-cli/src/commands/graph.rs | 144 ++++++++++-------- problemreductions-cli/src/mcp/tools.rs | 26 ++-- problemreductions-cli/src/test_support.rs | 6 +- problemreductions-cli/tests/cli_tests.rs | 34 +++++ problemreductions-macros/src/lib.rs | 21 ++- src/models/decision.rs | 4 +- src/models/graph/minimum_dominating_set.rs | 3 +- src/models/graph/minimum_vertex_cover.rs | 2 +- src/random.rs | 14 +- src/registry/mod.rs | 24 +-- src/registry/problem_type.rs | 12 +- src/registry/variant.rs | 15 +- src/rules/graph.rs | 18 +-- src/unit_tests/registry/variant.rs | 16 +- src/unit_tests/rules/graph.rs | 25 +-- 18 files changed, 203 insertions(+), 182 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5d8678057..23b39cfa9 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -66,7 +66,7 @@ Examples: #[arg(long)] rules: bool, - /// Restrict problems to a model category such as graph, set, or scheduling + /// Restrict problems to a model category such as graph, set, or misc #[arg(long, conflicts_with = "rules")] category: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index e57d771e4..e3a37c608 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -386,17 +386,17 @@ fn create_registered_random( "No concrete variant is registered for {canonical} with {resolved_variant:?}" ) })?; - let inputs = entry.random_inputs.ok_or_else(|| { + let random = entry.random.ok_or_else(|| { anyhow::anyhow!( "Random generation is not registered for {}", problemreductions::registry::variant::variant_label(entry) ) })?; + let inputs = random.inputs; let data = normalize_registered_create_inputs(args, inputs, resolved_variant) .map_err(|error| with_registered_usage(error, canonical, inputs))?; - let problem = - problemreductions::registry::generate_random_dyn(canonical, resolved_variant, data) - .map_err(|error| with_registered_usage(error.into(), canonical, inputs))?; + let problem = (random.generate)(data) + .map_err(|error| with_registered_usage(error.into(), canonical, inputs))?; let variant = problem.variant_map(); anyhow::ensure!( problem.problem_name() == canonical && variant == *resolved_variant, diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 06862bf99..47caf8589 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -112,7 +112,7 @@ pub(super) fn create_schema_driven( if let Some(inputs) = variant_entry.create_inputs { let data = normalize_registered_create_inputs(args, inputs, resolved_variant) .map_err(|error| with_registered_usage(error, canonical, inputs))?; - return construct_canonical(canonical, resolved_variant, data) + return construct_canonical(variant_entry, canonical, resolved_variant, data) .map_err(|error| with_registered_usage(error, canonical, inputs)); } @@ -143,15 +143,16 @@ pub(super) fn create_schema_driven( } let data = serde_json::Value::Object(json_map); - construct_canonical(canonical, resolved_variant, data) + construct_canonical(variant_entry, canonical, resolved_variant, data) } fn construct_canonical( + entry: &problemreductions::registry::VariantEntry, canonical: &str, resolved_variant: &BTreeMap, data: serde_json::Value, ) -> Result<(serde_json::Value, BTreeMap)> { - let problem = problemreductions::registry::construct_dyn(canonical, resolved_variant, data)?; + let problem = (entry.construct_fn)(data)?; let constructed_variant = problem.variant_map(); anyhow::ensure!( problem.problem_name() == canonical && constructed_variant == *resolved_variant, @@ -356,14 +357,14 @@ pub(crate) fn create_inputs_for( ); } } - if let Some(random_inputs) = variant_entry.random_inputs { + if let Some(random) = variant_entry.random { insert_create_input( &mut inputs, "random", InputValueKind::Bool, "random generation", ); - for input in random_inputs { + for input in random.inputs { let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); insert_create_input( &mut inputs, diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 34cbbbc66..e2c74732f 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -18,15 +18,24 @@ pub fn list( ) -> Result<()> { use crate::output::{format_table, Align}; - let graph = ReductionGraph::new(); - + let needs_variant_rows = verbose || out.json || out.output.is_some(); let catalog = problemreductions::registry::problem_types(); let mut variant_aliases = BTreeMap::<&str, Vec<&str>>::new(); + let mut variant_counts = BTreeMap::<&str, usize>::new(); + let mut aliases_by_variant = + BTreeMap::<&str, BTreeMap, &'static [&'static str]>>::new(); for entry in problemreductions::registry::variant_entries() { variant_aliases .entry(entry.name) .or_default() .extend(entry.aliases); + *variant_counts.entry(entry.name).or_default() += 1; + if needs_variant_rows { + aliases_by_variant + .entry(entry.name) + .or_default() + .insert(entry.variant_map(), entry.aliases); + } } let query = query.map(str::to_lowercase); let category = category.map(str::to_lowercase); @@ -55,15 +64,11 @@ pub fn list( }) }) .collect::>(); - let selected_names = selected - .iter() - .map(|problem| problem.canonical_name) - .collect::>(); - let needs_all_variant_rows = out.json || out.output.is_some(); + let graph = needs_variant_rows.then(ReductionGraph::new); + let num_reductions = problemreductions::rules::registry::reduction_entries().len(); // Collect data: one row per variant, grouped by problem type. struct VariantRow { - problem: &'static str, /// Full problem/variant name (e.g., "MIS/SimpleGraph/i32") display: String, /// Aliases (shown only on first variant of each problem) @@ -77,53 +82,50 @@ pub fn list( } let mut rows_data: Vec = Vec::new(); - for problem in &catalog { - let name = problem.canonical_name; - if !needs_all_variant_rows && (!verbose || !selected_names.contains(name)) { - continue; - } - let variants = graph.variants_for(name); - let default_variant = graph.default_variant_for(name); - let problem_aliases = aliases_for(name); - - for (i, v) in variants.iter().enumerate() { - let slash = variant_to_full_slash(v); - let display = if slash.is_empty() { - name.to_string() - } else { - format!("{name}{slash}") - }; - let is_default = default_variant.as_ref() == Some(v); + if let Some(graph) = &graph { + for problem in &selected { + let name = problem.canonical_name; + let variants = graph.variants_for(name); + let default_variant = graph.default_variant_for(name); + let problem_aliases = aliases_for(name); let rules = graph.outgoing_reductions(name).len(); - let complexity = graph - .variant_complexity(name, v) - .map(|c| big_o_of(&Expr::parse(c))) - .unwrap_or_default(); - - // Per-row aliases: problem-level aliases on the first row, plus any - // variant-level aliases attached to the specific reduction-graph node. - let variant_aliases: Vec<&'static str> = - problemreductions::registry::find_variant_entry(name, v) - .map(|entry| entry.aliases.to_vec()) + + for (i, v) in variants.iter().enumerate() { + let slash = variant_to_full_slash(v); + let display = if slash.is_empty() { + name.to_string() + } else { + format!("{name}{slash}") + }; + let is_default = default_variant.as_ref() == Some(v); + let complexity = graph + .variant_complexity(name, v) + .map(|c| big_o_of(&Expr::parse(c))) .unwrap_or_default(); - let mut parts: Vec = Vec::new(); - if i == 0 { - for alias in &problem_aliases { - push_alias_part(&mut parts, alias); + + let mut parts: Vec = Vec::new(); + if i == 0 { + for alias in &problem_aliases { + push_alias_part(&mut parts, alias); + } + } + if let Some(aliases) = aliases_by_variant + .get(name) + .and_then(|by_variant| by_variant.get(v)) + { + for alias in *aliases { + push_alias_part(&mut parts, alias); + } } - } - for alias in &variant_aliases { - push_alias_part(&mut parts, alias); - } - rows_data.push(VariantRow { - problem: name, - display, - aliases: parts.join(", "), - is_default, - rules: if i == 0 { rules } else { 0 }, - complexity, - }); + rows_data.push(VariantRow { + display, + aliases: parts.join(", "), + is_default, + rules: if i == 0 { rules } else { 0 }, + complexity, + }); + } } } @@ -141,11 +143,7 @@ pub fn list( ("Complexity", Align::Left, 10), ]; - let visible_rows = rows_data - .iter() - .filter(|row| selected_names.contains(row.problem)) - .collect::>(); - let rows: Vec> = visible_rows + let rows: Vec> = rows_data .iter() .map(|r| { let label = if r.is_default { @@ -174,9 +172,9 @@ pub fn list( "{}\n\n", crate::output::fmt_section(&format!( "Registered catalog: {} problem types, {} variant nodes, {} reduction rules", - graph.num_types(), - graph.num_variant_nodes(), - graph.num_reductions(), + catalog.len(), + variant_counts.values().sum::(), + num_reductions, )) ); if expanded { @@ -204,7 +202,11 @@ pub fn list( problem.canonical_name.to_string(), aliases.join(", "), problem.category.unwrap_or("uncategorized").to_string(), - graph.variants_for(problem.canonical_name).len().to_string(), + variant_counts + .get(problem.canonical_name) + .copied() + .unwrap_or_default() + .to_string(), ] }) .collect::>(); @@ -236,9 +238,9 @@ pub fn list( } let json = serde_json::json!({ - "num_types": graph.num_types(), - "num_reductions": graph.num_reductions(), - "num_variant_nodes": graph.num_variant_nodes(), + "num_types": selected.len(), + "num_reductions": num_reductions, + "num_variant_nodes": rows_data.len(), "variants": rows_data.iter().map(|r| { serde_json::json!({ "name": r.display, @@ -256,6 +258,17 @@ pub fn list( pub fn list_rules(query: Option<&str>, all: bool, verbose: bool, out: &OutputConfig) -> Result<()> { use crate::output::{format_table, Align}; + let num_registered = problemreductions::rules::registry::reduction_entries().len(); + let expanded = all || query.is_some() || verbose || out.json || out.output.is_some(); + if !expanded { + let text = format!( + "{}\n\nSearch with `pred list --rules ` or use `pred list --rules --all`. Add `--verbose` for size contracts.\n", + crate::output::fmt_section(&format!("Registered reduction rules: {num_registered}")) + ); + let json = serde_json::json!({ "num_rules": num_registered, "rules": [] }); + return out.emit_with_default_name("pred_rules_list.json", &text, &json); + } + let graph = ReductionGraph::new(); let mut types = graph.problem_types(); @@ -345,7 +358,6 @@ pub fn list_rules(query: Option<&str>, all: bool, verbose: bool, out: &OutputCon None, ]; - let expanded = all || query.is_some() || verbose; let mut text = format!( "{}\n", crate::output::fmt_section(&format!("Registered reduction rules: {}", rows_data.len())) @@ -374,8 +386,8 @@ pub fn list_rules(query: Option<&str>, all: bool, verbose: bool, out: &OutputCon } let json = serde_json::json!({ - "num_rules": rows_data.len(), - "rules": rows_data.iter().map(|r| { + "num_rules": selected.len(), + "rules": selected.iter().map(|r| { serde_json::json!({ "source": r.source, "target": r.target, diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index f51d70705..aa0de2f32 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -291,6 +291,12 @@ impl McpServer { let resolved = resolve_catalog_problem_ref(problem_type)?; let canonical = resolved.name().to_string(); let resolved_variant = resolved.variant().clone(); + let entry = problemreductions::registry::find_variant_entry(&canonical, &resolved_variant) + .ok_or_else(|| { + anyhow::anyhow!( + "No concrete variant is registered for {canonical} with {resolved_variant:?}" + ) + })?; // Check for random generation let is_random = params @@ -299,12 +305,11 @@ impl McpServer { .unwrap_or(false); if is_random { - return self.generate_registered_random_inner(&canonical, &resolved_variant, params); + return self.generate_registered_random_inner(entry, params); } let normalized = normalize_mcp_create_inputs(params)?; - let problem = - problemreductions::registry::construct_dyn(&canonical, &resolved_variant, normalized)?; + let problem = (entry.construct_fn)(normalized)?; let output = ProblemJsonOutput { problem_type: problem.problem_name().to_string(), @@ -316,8 +321,7 @@ impl McpServer { fn generate_registered_random_inner( &self, - canonical: &str, - resolved_variant: &BTreeMap, + entry: &problemreductions::registry::VariantEntry, params: &serde_json::Value, ) -> anyhow::Result { let mut inputs = params @@ -325,11 +329,13 @@ impl McpServer { .ok_or_else(|| anyhow::anyhow!("random inputs must be a JSON object"))? .clone(); inputs.remove("random"); - let problem = problemreductions::registry::generate_random_dyn( - canonical, - resolved_variant, - serde_json::Value::Object(inputs), - )?; + let random = entry.random.ok_or_else(|| { + anyhow::anyhow!( + "Random generation is not registered for {}", + problemreductions::registry::variant::variant_label(entry) + ) + })?; + let problem = (random.generate)(serde_json::Value::Object(inputs))?; let output = ProblemJsonOutput { problem_type: problem.problem_name().to_string(), variant: problem.variant_map(), diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index cbd8a745a..3ef84f177 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -160,8 +160,7 @@ problemreductions::inventory::submit! { .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; Ok(Box::new(problem)) }, - random_inputs: None, - random_fn: None, + random: None, factory: |data| { let problem: AggregateValueSource = serde_json::from_value(data)?; Ok(Box::new(problem)) @@ -189,8 +188,7 @@ problemreductions::inventory::submit! { .map_err(|error| problemreductions::registry::ConstructionError::InvalidInput(error.to_string()))?; Ok(Box::new(problem)) }, - random_inputs: None, - random_fn: None, + random: None, factory: |data| { let problem: AggregateValueTarget = serde_json::from_value(data)?; Ok(Box::new(problem)) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 0ea1d9d77..adcea1936 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -104,6 +104,24 @@ fn test_list_filters_by_category() { assert!(!stdout.contains("MaximumIndependentSet")); } +#[test] +fn test_list_json_respects_category_filter() { + let output = pred() + .args(["list", "--category", "formula", "--json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let variants = json["variants"].as_array().unwrap(); + assert_eq!(json["num_types"], 9); + assert!(variants + .iter() + .all(|variant| variant["name"] != "MaximumIndependentSet")); + assert!(variants + .iter() + .any(|variant| variant["name"] == "KSatisfiability/K3")); +} + #[test] fn test_list_searches_variant_aliases() { let output = pred().args(["list", "3SAT"]).output().unwrap(); @@ -200,6 +218,22 @@ fn test_list_rules_searches_problem_aliases() { assert!(stdout.contains("KSatisfiability")); } +#[test] +fn test_list_rules_json_respects_query() { + let output = pred() + .args(["list", "--rules", "3SAT", "--json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let rules = json["rules"].as_array().unwrap(); + assert_eq!(json["num_rules"].as_u64().unwrap() as usize, rules.len()); + assert!(rules.iter().all(|rule| { + rule["source"].as_str().unwrap().contains("KSatisfiability") + || rule["target"].as_str().unwrap().contains("KSatisfiability") + })); +} + #[test] fn test_show() { let output = pred().args(["show", "MIS"]).output().unwrap(); diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index b63ab6c71..a8e1bfa79 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -845,23 +845,22 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result::INPUTS) }, - quote! { - Some(|data: serde_json::Value| -> Result, crate::registry::ConstructionError> { + let random_registration = if random { + quote! { + Some(crate::registry::RandomRegistration { + inputs: <#ty as crate::registry::RandomGenerate>::INPUTS, + generate: |data: serde_json::Value| -> Result, crate::registry::ConstructionError> { Ok(Box::new(<#ty as crate::registry::RandomGenerate>::generate(data)?)) - }) - }, - ) + }, + }) + } } else { - (quote! { None }, quote! { None }) + quote! { None } }; let dispatch_fields = quote! { #construction_fields - random_inputs: #random_inputs, - random_fn: #random_fn, + random: #random_registration, factory: |data: serde_json::Value| -> Result, serde_json::Error> { let p: #ty = serde_json::from_value(data)?; Ok(Box::new(p)) diff --git a/src/models/decision.rs b/src/models/decision.rs index 9715725b9..6874d1f93 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -45,7 +45,7 @@ macro_rules! register_decision_variant { dims: [$($dim:expr),* $(,)?], fields: [$($field:expr),* $(,)?], size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] - $(, random: $random:ty)? + $(, $random:ident)? ) => { impl $crate::registry::CreateSpec for $crate::models::decision::DecisionCreateSpec<$inner> @@ -140,7 +140,7 @@ macro_rules! register_decision_variant { } }; - (@declare $inner:ty, $complexity:literal, $random:ty) => { + (@declare $inner:ty, $complexity:literal, random) => { $crate::declare_variants! { default $crate::models::decision::Decision<$inner> => $complexity create $crate::models::decision::DecisionCreateSpec<$inner> random, } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 85019a770..f3c5cad3f 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -281,8 +281,7 @@ inventory::submit! { .map(|problem| Box::new(problem) as Box) .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string())) }, - random_inputs: None, - random_fn: None, + random: None, factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 6dcadd879..95ef994c8 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -261,7 +261,7 @@ crate::register_decision_variant!( FieldInfo { name: "bound", type_name: "W::Sum", description: "Decision bound (maximum allowed cover cost)" }, ], size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)], - random: DecisionMinimumVertexCoverRandomSpec + random ); #[cfg(feature = "example-db")] diff --git a/src/random.rs b/src/random.rs index 81f4a07b9..30b136bee 100644 --- a/src/random.rs +++ b/src/random.rs @@ -202,14 +202,12 @@ pub fn create_random_graph(num_vertices: usize, edge_prob: f64, seed: Option) -> Vec<(i32, i32)> { let mut state = lcg_init(seed); let grid_size = (num_vertices as f64).sqrt().ceil() as i32 + 1; - let mut positions = std::collections::BTreeSet::new(); - while positions.len() < num_vertices { - positions.insert(( - (lcg_step(&mut state) * grid_size as f64) as i32, - (lcg_step(&mut state) * grid_size as f64) as i32, - )); - } - positions.into_iter().collect() + 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)]²`. diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 893b850d3..d1536462c 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -62,7 +62,8 @@ pub use schema::{ pub use variant::{ 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, VariantEntry, + ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, RandomGenerate, + RandomRegistration, VariantEntry, }; /// Construct a problem from normalized construction inputs using the exact @@ -81,27 +82,6 @@ pub fn construct_dyn( (entry.construct_fn)(data) } -/// Generate a problem using the exact variant's model-owned random generator. -pub fn generate_random_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(), - } - })?; - let generate = entry.random_fn.ok_or_else(|| { - ConstructionError::Conversion(format!( - "random generation is not registered for `{}`", - crate::registry::variant::variant_label(entry) - )) - })?; - generate(data) -} - use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 43b3f794e..85ada4acd 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -33,11 +33,7 @@ impl ProblemType { dimensions: entry.dimensions, description: entry.description, fields: entry.fields, - category: entry - .module_path - .split("::models::") - .nth(1) - .and_then(|path| path.split("::").next()), + category: problem_category_from_module_path(entry.module_path), } } @@ -50,6 +46,12 @@ impl ProblemType { } } +/// Extract a model category from `...::models::::...`. +pub(crate) fn problem_category_from_module_path(module_path: &str) -> Option<&str> { + let (_, model_path) = module_path.split_once("::models::")?; + model_path.split("::").next() +} + /// Find a problem type by exact canonical name. pub fn find_problem_type(name: &str) -> Option { inventory::iter:: diff --git a/src/registry/variant.rs b/src/registry/variant.rs index bd8a6552e..bfa8c4460 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -116,6 +116,15 @@ pub enum ConstructionError { 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. @@ -208,10 +217,8 @@ pub struct VariantEntry { pub create_inputs: Option<&'static [CreateInputInfo]>, /// Construct a validated concrete problem from normalized construction data. pub construct_fn: ConstructProblemFn, - /// Model-owned random generation inputs, when this variant supports generation. - pub random_inputs: Option<&'static [CreateInputInfo]>, - /// Generate a concrete random problem for this exact variant. - pub random_fn: Option, + /// 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. diff --git a/src/rules/graph.rs b/src/rules/graph.rs index e364c8e74..697a72c83 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -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 { @@ -1863,7 +1849,9 @@ impl ReductionGraph { /// /// 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() + crate::registry::problem_type::problem_category_from_module_path(module_path) + .unwrap_or("other") + .to_string() } /// Build the rustdoc path from a module path and problem name. diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index abbfc479e..3d33b3456 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -315,22 +315,16 @@ fn variant_label_with_variant_dimensions() { } #[test] -fn random_contract_metadata_and_generator_are_registered_together() { +fn random_contract_input_names_are_unique() { let entries = variant_entries(); - assert!(entries.iter().any(|entry| entry.random_fn.is_some())); + assert!(entries.iter().any(|entry| entry.random.is_some())); for entry in entries { - assert_eq!( - entry.random_inputs.is_some(), - entry.random_fn.is_some(), - "{} has a partial random-generation registration", - variant_label(entry) - ); - let Some(inputs) = entry.random_inputs else { + let Some(random) = entry.random else { continue; }; let mut names = BTreeSet::new(); - for input in inputs { + for input in random.inputs { assert!( !input.name.is_empty(), "{} has an empty random input", @@ -360,7 +354,7 @@ fn established_random_generation_models_remain_registered() { "; let registered = variant_entries() .into_iter() - .filter(|entry| entry.random_fn.is_some()) + .filter(|entry| entry.random.is_some()) .map(|entry| entry.name) .collect::>(); diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 1e09d066f..e1d2ed2bd 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::problem_type::problem_category_from_module_path; +use crate::rules::graph::{ReductionMode, ReductionStep}; use crate::rules::registry::{ReductionEntry, ReductionSizeDeclarations}; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; use crate::topology::SimpleGraph; @@ -1508,24 +1509,26 @@ fn test_edges_have_doc_paths() { } #[test] -fn test_classify_problem_category() { +fn test_problem_category_from_module_path() { assert_eq!( - classify_problem_category("problemreductions::models::graph::maximum_independent_set"), - "graph" + problem_category_from_module_path( + "problemreductions::models::graph::maximum_independent_set" + ), + Some("graph") ); assert_eq!( - classify_problem_category("problemreductions::models::formula::satisfiability"), - "formula" + problem_category_from_module_path("problemreductions::models::formula::satisfiability"), + Some("formula") ); assert_eq!( - classify_problem_category("problemreductions::models::set::maximum_set_packing"), - "set" + problem_category_from_module_path("problemreductions::models::set::maximum_set_packing"), + Some("set") ); assert_eq!( - classify_problem_category("problemreductions::models::algebraic::qubo"), - "algebraic" + problem_category_from_module_path("problemreductions::models::algebraic::qubo"), + Some("algebraic") ); - assert_eq!(classify_problem_category("unknown::path"), "other"); + assert_eq!(problem_category_from_module_path("unknown::path"), None); } #[test] From a2ff52aeaa034af1217496315624afc613852897 Mon Sep 17 00:00:00 2001 From: Xiwei Pan <90967972+isPANN@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:02:59 +0800 Subject: [PATCH 5/7] Declare model categories explicitly (#1136) * refactor: declare model categories explicitly * refactor: preserve typed categories in graph export --- .claude/CLAUDE.md | 3 +- .claude/skills/add-model/SKILL.md | 7 +- problemreductions-cli/src/cli.rs | 3 +- problemreductions-cli/src/commands/graph.rs | 49 +++++------ problemreductions-cli/src/main.rs | 2 +- problemreductions-cli/src/test_support.rs | 18 ++++ problemreductions-cli/tests/cli_tests.rs | 18 ++++ .../algebraic/algebraic_equations_over_gf2.rs | 1 + src/models/algebraic/bmf.rs | 1 + .../algebraic/closest_vector_problem.rs | 1 + .../consecutive_block_minimization.rs | 1 + .../consecutive_ones_matrix_augmentation.rs | 1 + .../algebraic/consecutive_ones_submatrix.rs | 1 + src/models/algebraic/equilibrium_point.rs | 1 + .../algebraic/feasible_basis_extension.rs | 1 + src/models/algebraic/ilp.rs | 1 + src/models/algebraic/minimum_matrix_cover.rs | 1 + .../algebraic/minimum_matrix_domination.rs | 1 + .../algebraic/minimum_weight_decoding.rs | 1 + ...mum_weight_solution_to_linear_equations.rs | 1 + src/models/algebraic/quadratic_assignment.rs | 1 + src/models/algebraic/quadratic_congruences.rs | 1 + .../quadratic_diophantine_equations.rs | 1 + src/models/algebraic/qubo.rs | 1 + .../algebraic/simultaneous_incongruences.rs | 1 + .../algebraic/sparse_matrix_compression.rs | 1 + src/models/decision.rs | 2 + src/models/formula/circuit.rs | 1 + src/models/formula/ksat.rs | 1 + .../formula/maximum_2_satisfiability.rs | 1 + src/models/formula/nae_satisfiability.rs | 1 + src/models/formula/non_tautology.rs | 1 + .../formula/one_in_three_satisfiability.rs | 1 + src/models/formula/planar_3_satisfiability.rs | 1 + src/models/formula/qbf.rs | 1 + src/models/formula/sat.rs | 1 + src/models/graph/acyclic_partition.rs | 1 + .../balanced_complete_bipartite_subgraph.rs | 1 + src/models/graph/biclique_cover.rs | 1 + .../graph/biconnectivity_augmentation.rs | 1 + .../graph/bottleneck_traveling_salesman.rs | 1 + .../bounded_component_spanning_forest.rs | 1 + .../graph/bounded_diameter_spanning_tree.rs | 1 + .../graph/degree_constrained_spanning_tree.rs | 1 + src/models/graph/directed_hamiltonian_path.rs | 1 + .../directed_two_commodity_integral_flow.rs | 1 + src/models/graph/disjoint_connecting_paths.rs | 1 + src/models/graph/eulerian_path.rs | 1 + src/models/graph/generalized_hex.rs | 1 + src/models/graph/graph_partitioning.rs | 1 + src/models/graph/hamiltonian_circuit.rs | 1 + src/models/graph/hamiltonian_path.rs | 1 + .../hamiltonian_path_between_two_vertices.rs | 1 + src/models/graph/highly_connected_deletion.rs | 1 + src/models/graph/integral_flow_bundles.rs | 1 + .../graph/integral_flow_homologous_arcs.rs | 1 + .../graph/integral_flow_with_multipliers.rs | 1 + src/models/graph/isomorphic_spanning_tree.rs | 1 + src/models/graph/kclique.rs | 1 + src/models/graph/kcoloring.rs | 1 + src/models/graph/kernel.rs | 1 + src/models/graph/kth_best_spanning_tree.rs | 1 + .../graph/length_bounded_disjoint_paths.rs | 1 + src/models/graph/longest_circuit.rs | 1 + src/models/graph/longest_path.rs | 1 + src/models/graph/max_cut.rs | 1 + src/models/graph/maximal_is.rs | 1 + src/models/graph/maximum_achromatic_number.rs | 1 + src/models/graph/maximum_clique.rs | 1 + src/models/graph/maximum_co_k_plex.rs | 1 + .../graph/maximum_common_edge_subgraph.rs | 1 + .../graph/maximum_contact_map_overlap.rs | 1 + src/models/graph/maximum_domatic_number.rs | 1 + .../graph/maximum_edge_weighted_k_clique.rs | 1 + src/models/graph/maximum_independent_set.rs | 1 + .../graph/maximum_leaf_spanning_tree.rs | 1 + src/models/graph/maximum_matching.rs | 1 + src/models/graph/min_max_multicenter.rs | 1 + .../minimum_capacitated_spanning_tree.rs | 1 + src/models/graph/minimum_cost_circulation.rs | 1 + src/models/graph/minimum_cost_maximum_flow.rs | 1 + .../graph/minimum_covering_by_cliques.rs | 1 + .../graph/minimum_cut_into_bounded_sets.rs | 1 + src/models/graph/minimum_dominating_set.rs | 2 + .../graph/minimum_dummy_activities_pert.rs | 1 + src/models/graph/minimum_edge_cost_flow.rs | 1 + src/models/graph/minimum_feedback_arc_set.rs | 1 + .../graph/minimum_feedback_vertex_set.rs | 1 + ...imum_geometric_connected_dominating_set.rs | 1 + src/models/graph/minimum_graph_bandwidth.rs | 1 + .../graph/minimum_intersection_graph_basis.rs | 1 + src/models/graph/minimum_maximal_matching.rs | 1 + src/models/graph/minimum_metric_dimension.rs | 1 + src/models/graph/minimum_multiway_cut.rs | 1 + src/models/graph/minimum_sum_multicenter.rs | 1 + src/models/graph/minimum_vertex_cover.rs | 2 + src/models/graph/mixed_chinese_postman.rs | 1 + src/models/graph/monochromatic_triangle.rs | 1 + src/models/graph/multiple_choice_branching.rs | 1 + .../graph/multiple_copy_file_allocation.rs | 1 + .../graph/optimal_linear_arrangement.rs | 2 + src/models/graph/partial_feedback_edge_set.rs | 1 + src/models/graph/partition_into_cliques.rs | 1 + src/models/graph/partition_into_forests.rs | 1 + .../graph/partition_into_paths_of_length_2.rs | 1 + .../graph/partition_into_perfect_matchings.rs | 1 + src/models/graph/partition_into_triangles.rs | 1 + .../graph/path_constrained_network_flow.rs | 1 + .../graph/prize_collecting_steiner_forest.rs | 1 + src/models/graph/rooted_tree_arrangement.rs | 1 + src/models/graph/rural_postman.rs | 1 + .../graph/shortest_weight_constrained_path.rs | 1 + src/models/graph/spin_glass.rs | 1 + src/models/graph/steiner_tree.rs | 1 + src/models/graph/steiner_tree_in_graphs.rs | 1 + .../graph/strong_connectivity_augmentation.rs | 1 + src/models/graph/subgraph_isomorphism.rs | 1 + src/models/graph/traveling_salesman.rs | 1 + .../graph/undirected_flow_lower_bounds.rs | 1 + .../undirected_two_commodity_integral_flow.rs | 1 + src/models/misc/additional_key.rs | 1 + src/models/misc/betweenness.rs | 1 + src/models/misc/bin_packing.rs | 1 + .../misc/boyce_codd_normal_form_violation.rs | 1 + src/models/misc/capacity_assignment.rs | 1 + src/models/misc/closest_string.rs | 1 + src/models/misc/closest_substring.rs | 1 + src/models/misc/clustering.rs | 1 + src/models/misc/conjunctive_boolean_query.rs | 1 + .../misc/conjunctive_query_foldability.rs | 1 + ...onsistency_of_database_frequency_tables.rs | 1 + src/models/misc/cosine_product_integration.rs | 1 + src/models/misc/cyclic_ordering.rs | 1 + src/models/misc/dynamic_storage_allocation.rs | 1 + src/models/misc/ensemble_computation.rs | 1 + src/models/misc/expected_retrieval_cost.rs | 1 + src/models/misc/factoring.rs | 1 + .../misc/feasible_register_assignment.rs | 1 + src/models/misc/flow_shop_scheduling.rs | 1 + src/models/misc/grouping_by_swapping.rs | 1 + .../misc/integer_expression_membership.rs | 1 + src/models/misc/job_shop_scheduling.rs | 1 + src/models/misc/knapsack.rs | 1 + src/models/misc/kth_largest_m_tuple.rs | 1 + src/models/misc/longest_common_subsequence.rs | 1 + src/models/misc/maximum_likelihood_ranking.rs | 1 + src/models/misc/minimum_axiom_set.rs | 1 + .../minimum_code_generation_one_register.rs | 1 + ...um_code_generation_parallel_assignments.rs | 1 + ...mum_code_generation_unlimited_registers.rs | 1 + src/models/misc/minimum_decision_tree.rs | 1 + ...imum_discrete_planar_inverse_kinematics.rs | 1 + .../misc/minimum_disjunctive_normal_form.rs | 1 + ...minimum_external_macro_data_compression.rs | 1 + .../misc/minimum_fault_detection_test_set.rs | 1 + ...minimum_internal_macro_data_compression.rs | 1 + .../minimum_register_sufficiency_for_loops.rs | 1 + .../misc/minimum_tardiness_sequencing.rs | 1 + .../misc/minimum_weight_and_or_graph.rs | 1 + src/models/misc/multiprocessor_scheduling.rs | 1 + .../misc/non_liveness_free_petri_net.rs | 1 + .../misc/numerical_3_dimensional_matching.rs | 1 + .../numerical_matching_with_target_sums.rs | 1 + src/models/misc/open_shop_scheduling.rs | 1 + .../optimum_communication_spanning_tree.rs | 1 + src/models/misc/paintshop.rs | 1 + src/models/misc/partially_ordered_knapsack.rs | 1 + src/models/misc/partition.rs | 1 + .../misc/precedence_constrained_scheduling.rs | 1 + src/models/misc/preemptive_scheduling.rs | 1 + src/models/misc/production_planning.rs | 1 + .../misc/rectilinear_picture_compression.rs | 1 + src/models/misc/register_sufficiency.rs | 1 + .../misc/resource_constrained_scheduling.rs | 1 + ...ng_to_minimize_weighted_completion_time.rs | 1 + .../scheduling_with_individual_deadlines.rs | 1 + ...ing_to_minimize_maximum_cumulative_cost.rs | 1 + ...equencing_to_minimize_tardy_task_weight.rs | 1 + ...ng_to_minimize_weighted_completion_time.rs | 1 + ...quencing_to_minimize_weighted_tardiness.rs | 1 + ...uencing_with_deadlines_and_set_up_times.rs | 1 + ...encing_with_release_times_and_deadlines.rs | 1 + .../misc/sequencing_within_intervals.rs | 1 + .../misc/shortest_common_supersequence.rs | 1 + .../misc/shortest_common_superstring.rs | 1 + src/models/misc/square_tiling.rs | 1 + src/models/misc/stacker_crane.rs | 1 + src/models/misc/staff_scheduling.rs | 1 + .../misc/string_to_string_correction.rs | 1 + src/models/misc/subset_product.rs | 1 + src/models/misc/subset_sum.rs | 1 + src/models/misc/sum_of_squares_partition.rs | 1 + src/models/misc/three_partition.rs | 1 + src/models/misc/timetable_design.rs | 1 + src/models/set/comparative_containment.rs | 1 + src/models/set/consecutive_sets.rs | 1 + src/models/set/exact_cover_by_3_sets.rs | 1 + src/models/set/integer_knapsack.rs | 1 + src/models/set/maximum_set_packing.rs | 1 + src/models/set/minimum_cardinality_key.rs | 1 + src/models/set/minimum_hitting_set.rs | 1 + src/models/set/minimum_set_covering.rs | 1 + src/models/set/prime_attribute_name.rs | 1 + .../set/rooted_tree_storage_assignment.rs | 1 + src/models/set/set_basis.rs | 1 + src/models/set/set_splitting.rs | 1 + src/models/set/three_dimensional_matching.rs | 1 + src/models/set/three_matroid_intersection.rs | 1 + .../set/two_dimensional_consecutive_sets.rs | 1 + src/registry/mod.rs | 5 +- src/registry/problem_type.rs | 14 +-- src/registry/schema.rs | 88 +++++++++++++++++++ src/rules/graph.rs | 41 ++++----- src/unit_tests/registry/problem_type.rs | 39 +++++++- src/unit_tests/registry/schema.rs | 15 ++++ src/unit_tests/rules/graph.rs | 75 +++------------- 216 files changed, 450 insertions(+), 133 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9ac350bc8..97890640c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -153,7 +153,7 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `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. 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,6 +204,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI +- **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 deferred:** `pred create` expands flags only for the selected concrete variant. Ordinary models use `ProblemSchemaEntry.fields` directly. Models whose construction differs from persisted JSON own a typed `CreateSpec` and fallible conversion beside the model; CLI and MCP only normalize transport values and invoke the registered constructor. - **Each construction input has one name and one concrete type per variant.** Do not add compatibility aliases or infer types from flag names. `CreateSpec` field names render as `snake_case → kebab-case` in CLI and remain `snake_case` in MCP. Add a reusable codec only for a genuinely new transport representation, never a model-name parser branch. - **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. diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 9fb5b5050..87e472566 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -75,7 +75,7 @@ Read these first to understand the patterns: 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 construction interface (`display_name`, `aliases`, `dimensions`, and `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 @@ -92,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. @@ -122,7 +124,7 @@ Create `src/models//.rs`: ``` Key decisions: -- **Schema metadata:** `ProblemSchemaEntry` must reflect the construction interface, including `display_name`, `aliases`, `dimensions`, and `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 @@ -313,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` | diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 23b39cfa9..4a8138af1 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,4 +1,5 @@ use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +use problemreductions::registry::ProblemCategory; use std::path::PathBuf; pub use crate::create_args::CreateArgs; @@ -68,7 +69,7 @@ Examples: /// Restrict problems to a model category such as graph, set, or misc #[arg(long, conflicts_with = "rules")] - category: Option, + category: Option, /// List the complete catalog instead of the summary #[arg(long)] diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index e2c74732f..5cd72f3a7 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -3,6 +3,7 @@ use crate::output::OutputConfig; use crate::problem_name::{aliases_for, parse_problem_spec, resolve_problem_ref}; use anyhow::Result; use problemreductions::registry::collect_schemas; +use problemreductions::registry::ProblemCategory; use problemreductions::rules::{MeasuredPath, ReductionGraph, ReductionPath, TraversalFlow}; use problemreductions::{Expr, Growth}; use std::any::Any; @@ -11,7 +12,7 @@ use std::path::Path; pub fn list( query: Option<&str>, - category: Option<&str>, + category: Option, all: bool, verbose: bool, out: &OutputConfig, @@ -38,30 +39,25 @@ pub fn list( } } let query = query.map(str::to_lowercase); - let category = category.map(str::to_lowercase); let selected = catalog .iter() .filter(|problem| { - category.as_ref().is_none_or(|wanted| { - problem - .category - .unwrap_or("uncategorized") - .eq_ignore_ascii_case(wanted) - }) && query.as_ref().is_none_or(|needle| { - problem.canonical_name.to_lowercase().contains(needle) - || problem.display_name.to_lowercase().contains(needle) - || problem - .aliases - .iter() - .any(|alias| alias.to_lowercase().contains(needle)) - || variant_aliases - .get(problem.canonical_name) - .is_some_and(|aliases| { - aliases - .iter() - .any(|alias| alias.to_lowercase().contains(needle)) - }) - }) + category.is_none_or(|wanted| problem.category == wanted) + && query.as_ref().is_none_or(|needle| { + problem.canonical_name.to_lowercase().contains(needle) + || problem.display_name.to_lowercase().contains(needle) + || problem + .aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + || variant_aliases + .get(problem.canonical_name) + .is_some_and(|aliases| { + aliases + .iter() + .any(|alias| alias.to_lowercase().contains(needle)) + }) + }) }) .collect::>(); let graph = needs_variant_rows.then(ReductionGraph::new); @@ -79,6 +75,7 @@ pub fn list( rules: usize, /// Best-known complexity complexity: String, + category: ProblemCategory, } let mut rows_data: Vec = Vec::new(); @@ -124,6 +121,7 @@ pub fn list( is_default, rules: if i == 0 { rules } else { 0 }, complexity, + category: problem.category, }); } } @@ -131,9 +129,7 @@ pub fn list( let mut category_counts = BTreeMap::new(); for problem in &catalog { - *category_counts - .entry(problem.category.unwrap_or("uncategorized")) - .or_insert(0usize) += 1; + *category_counts.entry(problem.category).or_insert(0usize) += 1; } let columns: Vec<(&str, Align, usize)> = vec![ @@ -201,7 +197,7 @@ pub fn list( vec![ problem.canonical_name.to_string(), aliases.join(", "), - problem.category.unwrap_or("uncategorized").to_string(), + problem.category.to_string(), variant_counts .get(problem.canonical_name) .copied() @@ -248,6 +244,7 @@ pub fn list( "default": r.is_default, "rules": r.rules, "complexity": r.complexity, + "category": r.category, }) }).collect::>(), }); diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 6cb313b29..aa4361ae5 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -60,7 +60,7 @@ fn main() -> anyhow::Result<()> { if rules { commands::graph::list_rules(query.as_deref(), all, verbose, &out) } else { - commands::graph::list(query.as_deref(), category.as_deref(), all, verbose, &out) + commands::graph::list(query.as_deref(), category, all, verbose, &out) } } Commands::Show { problem } => commands::graph::show(&problem, &out), diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 3ef84f177..539e03a7d 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -135,6 +135,7 @@ problemreductions::inventory::submit! { display_name: "CLI test aggregate value source", aliases: &[], dimensions: &[], + category: problemreductions::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Test-only dynamically discovered construction model", fields: &[FieldInfo { @@ -145,6 +146,23 @@ problemreductions::inventory::submit! { } } +problemreductions::inventory::submit! { + ProblemSchemaEntry { + name: AggregateValueTarget::NAME, + display_name: "CLI test aggregate value target", + aliases: &[], + dimensions: &[], + category: problemreductions::registry::ProblemCategory::Misc, + module_path: module_path!(), + description: "Test-only aggregate reduction target", + fields: &[FieldInfo { + name: "base", + type_name: "u64", + description: "Base aggregate value", + }], + } +} + problemreductions::inventory::submit! { VariantEntry { name: AggregateValueSource::NAME, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index adcea1936..44f8a31d8 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -88,6 +88,9 @@ fn test_list() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("Registered catalog")); assert!(stdout.contains("graph")); + for category in ["algebraic", "formula", "graph", "misc", "set"] { + assert!(stdout.contains(category)); + } assert!(!stdout.contains("MaximumIndependentSet")); assert!(stdout.lines().count() < 30, "default list is too verbose"); } @@ -117,11 +120,26 @@ fn test_list_json_respects_category_filter() { assert!(variants .iter() .all(|variant| variant["name"] != "MaximumIndependentSet")); + assert!(variants + .iter() + .all(|variant| variant["category"] == "formula")); assert!(variants .iter() .any(|variant| variant["name"] == "KSatisfiability/K3")); } +#[test] +fn test_list_category_rejects_unknown_value() { + let output = pred() + .args(["list", "--category", "unknown"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("unknown problem category `unknown`")); + assert!(stderr.contains("algebraic, formula, graph, misc, set")); +} + #[test] fn test_list_searches_variant_aliases() { let output = pred().args(["list", "3SAT"]).output().unwrap(); diff --git a/src/models/algebraic/algebraic_equations_over_gf2.rs b/src/models/algebraic/algebraic_equations_over_gf2.rs index be8d8c334..2f9372585 100644 --- a/src/models/algebraic/algebraic_equations_over_gf2.rs +++ b/src/models/algebraic/algebraic_equations_over_gf2.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Algebraic Equations over GF(2)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find assignment satisfying multilinear polynomial equations over GF(2)", fields: &[ diff --git a/src/models/algebraic/bmf.rs b/src/models/algebraic/bmf.rs index 455514fe8..5ac8f429c 100644 --- a/src/models/algebraic/bmf.rs +++ b/src/models/algebraic/bmf.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "BMF", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Boolean matrix factorization", fields: &[ diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index 2070593a2..a6d004a28 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Closest Vector Problem", aliases: &["CVP"], dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find the closest lattice point to a target vector", fields: ClosestVectorProblemI32CreateSpec::FIELDS, diff --git a/src/models/algebraic/consecutive_block_minimization.rs b/src/models/algebraic/consecutive_block_minimization.rs index d816abed5..5b5efc62d 100644 --- a/src/models/algebraic/consecutive_block_minimization.rs +++ b/src/models/algebraic/consecutive_block_minimization.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Consecutive Block Minimization", aliases: &["CBM"], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Permute columns of a binary matrix to have at most K consecutive blocks of 1s", fields: ConsecutiveBlockMinimizationCreateSpec::FIELDS, diff --git a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs index 10daa96cd..8337ffe96 100644 --- a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Consecutive Ones Matrix Augmentation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Augment a binary matrix with at most K zero-to-one flips so some column permutation has the consecutive ones property", fields: ConsecutiveOnesMatrixAugmentationCreateSpec::FIELDS, diff --git a/src/models/algebraic/consecutive_ones_submatrix.rs b/src/models/algebraic/consecutive_ones_submatrix.rs index 3b7308ddc..85e8834a4 100644 --- a/src/models/algebraic/consecutive_ones_submatrix.rs +++ b/src/models/algebraic/consecutive_ones_submatrix.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Consecutive Ones Submatrix", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find K columns of a binary matrix that can be permuted to have the consecutive ones property", fields: &[ diff --git a/src/models/algebraic/equilibrium_point.rs b/src/models/algebraic/equilibrium_point.rs index d87f37c3b..c987b8f17 100644 --- a/src/models/algebraic/equilibrium_point.rs +++ b/src/models/algebraic/equilibrium_point.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Equilibrium Point", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Decide whether a pure-strategy Nash equilibrium exists for a multi-player game with polynomial payoff functions", fields: &[ diff --git a/src/models/algebraic/feasible_basis_extension.rs b/src/models/algebraic/feasible_basis_extension.rs index bcdfbf816..4866ed45d 100644 --- a/src/models/algebraic/feasible_basis_extension.rs +++ b/src/models/algebraic/feasible_basis_extension.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Feasible Basis Extension", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Given matrix A, vector a_bar, and required columns S, find a feasible basis extending S", fields: FeasibleBasisExtensionCreateSpec::FIELDS, diff --git a/src/models/algebraic/ilp.rs b/src/models/algebraic/ilp.rs index 2a890aa4e..c58ff7e23 100644 --- a/src/models/algebraic/ilp.rs +++ b/src/models/algebraic/ilp.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "ILP", aliases: &[], dimensions: &[VariantDimension::new("variable", "bool", &["bool", "i32"])], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Optimize linear objective subject to linear constraints", fields: &[ diff --git a/src/models/algebraic/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index 7aada4ce1..1474c10e2 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Minimum Matrix Cover", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find sign assignment minimizing quadratic form over nonnegative integer matrix", fields: &[ diff --git a/src/models/algebraic/minimum_matrix_domination.rs b/src/models/algebraic/minimum_matrix_domination.rs index 49fc9a2ee..b633984c9 100644 --- a/src/models/algebraic/minimum_matrix_domination.rs +++ b/src/models/algebraic/minimum_matrix_domination.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Minimum Matrix Domination", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find minimum subset of 1-entries in a binary matrix that dominates all other 1-entries by shared row or column", fields: &[ diff --git a/src/models/algebraic/minimum_weight_decoding.rs b/src/models/algebraic/minimum_weight_decoding.rs index 9cc5dab83..18d42a760 100644 --- a/src/models/algebraic/minimum_weight_decoding.rs +++ b/src/models/algebraic/minimum_weight_decoding.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Minimum Weight Decoding", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find minimum Hamming weight binary vector x such that Hx ≡ s (mod 2)", fields: MinimumWeightDecodingCreateSpec::FIELDS, diff --git a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs index 7c33f0eb2..ff04f5042 100644 --- a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Minimum Weight Solution to Linear Equations", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find a rational solution to Ay=b minimizing the number of non-zero entries", fields: MinimumWeightSolutionCreateSpec::FIELDS, diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index 2582d3106..741ef103a 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Quadratic Assignment", aliases: &["QAP"], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Minimize total cost of assigning facilities to locations", fields: &[ diff --git a/src/models/algebraic/quadratic_congruences.rs b/src/models/algebraic/quadratic_congruences.rs index 12ba2fc3c..a09cca568 100644 --- a/src/models/algebraic/quadratic_congruences.rs +++ b/src/models/algebraic/quadratic_congruences.rs @@ -22,6 +22,7 @@ inventory::submit! { display_name: "Quadratic Congruences", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Decide whether x² ≡ a (mod b) has a solution for x in {1, ..., c-1}", fields: &[ diff --git a/src/models/algebraic/quadratic_diophantine_equations.rs b/src/models/algebraic/quadratic_diophantine_equations.rs index 7fdc29844..087b780eb 100644 --- a/src/models/algebraic/quadratic_diophantine_equations.rs +++ b/src/models/algebraic/quadratic_diophantine_equations.rs @@ -20,6 +20,7 @@ inventory::submit! { display_name: "Quadratic Diophantine Equations", aliases: &["QDE"], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Decide whether ax^2 + by = c has a solution in positive integers x, y", fields: &[ diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 73bb5e6ff..e15eba615 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -13,6 +13,7 @@ inventory::submit! { display_name: "QUBO", aliases: &[], dimensions: &[VariantDimension::new("weight", "f64", &["f64"])], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Minimize quadratic unconstrained binary objective", fields: QuboCreateSpec::FIELDS, diff --git a/src/models/algebraic/simultaneous_incongruences.rs b/src/models/algebraic/simultaneous_incongruences.rs index 5dc6263d1..bf590a7db 100644 --- a/src/models/algebraic/simultaneous_incongruences.rs +++ b/src/models/algebraic/simultaneous_incongruences.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Simultaneous Incongruences", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Decide whether there exists x with x ≢ aᵢ (mod bᵢ) for all i", fields: &[ diff --git a/src/models/algebraic/sparse_matrix_compression.rs b/src/models/algebraic/sparse_matrix_compression.rs index 186b8a1a6..a92d8fc07 100644 --- a/src/models/algebraic/sparse_matrix_compression.rs +++ b/src/models/algebraic/sparse_matrix_compression.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Sparse Matrix Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Overlay binary-matrix rows into a short storage vector by shifting each row without collisions", fields: SparseMatrixCompressionCreateSpec::FIELDS, diff --git a/src/models/decision.rs b/src/models/decision.rs index 6874d1f93..e9414ff0f 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -42,6 +42,7 @@ macro_rules! register_decision_variant { $complexity:literal, $aliases:expr, $description:literal, + category: $category:expr, dims: [$($dim:expr),* $(,)?], fields: [$($field:expr),* $(,)?], size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] @@ -64,6 +65,7 @@ macro_rules! register_decision_variant { display_name: $crate::register_decision_variant!(@display_name $name), aliases: $aliases, dimensions: &[$($dim),*], + category: $category, module_path: module_path!(), description: $description, fields: &[$($field),*], 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 acec604d4..1510dedfc 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -21,6 +21,7 @@ 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: AcyclicPartitionCreateSpec::FIELDS, diff --git a/src/models/graph/balanced_complete_bipartite_subgraph.rs b/src/models/graph/balanced_complete_bipartite_subgraph.rs index 9f7bd8122..6609764f4 100644 --- a/src/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/models/graph/balanced_complete_bipartite_subgraph.rs @@ -10,6 +10,7 @@ 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: BalancedCompleteBipartiteSubgraphCreateSpec::FIELDS, diff --git a/src/models/graph/biclique_cover.rs b/src/models/graph/biclique_cover.rs index 63c36f754..69b5b6a95 100644 --- a/src/models/graph/biclique_cover.rs +++ b/src/models/graph/biclique_cover.rs @@ -26,6 +26,7 @@ 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: BicliqueCoverCreateSpec::FIELDS, diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 2b4d5949f..70e084a22 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -21,6 +21,7 @@ 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: BiconnectivityAugmentationCreateSpec::FIELDS, diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 030f55f3c..ea0b841bc 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -15,6 +15,7 @@ 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: BottleneckTravelingSalesmanCreateSpec::FIELDS, diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index c9d0c09d3..68dc4e49d 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -21,6 +21,7 @@ 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: BoundedComponentSpanningForestCreateSpec::FIELDS, diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 42b5561a8..16b580dc4 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -22,6 +22,7 @@ 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: BoundedDiameterSpanningTreeCreateSpec::FIELDS, 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 b48a1f1af..92599eb1e 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -18,6 +18,7 @@ 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: DisjointConnectingPathsCreateSpec::FIELDS, 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 06f9d7914..0e44aef52 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -20,6 +20,7 @@ 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: GeneralizedHexCreateSpec::FIELDS, 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 a66d85684..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: &[ diff --git a/src/models/graph/hamiltonian_path.rs b/src/models/graph/hamiltonian_path.rs index a50787e0b..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: &[ diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index a11c6fdb9..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: &[ 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 70e9f5eb4..935c2076c 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -15,6 +15,7 @@ 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: IntegralFlowBundlesCreateSpec::FIELDS, diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 29a0c5001..c54f7ea72 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -15,6 +15,7 @@ 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: IntegralFlowHomologousArcsCreateSpec::FIELDS, diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index f8ed72103..7fda1c4a8 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -15,6 +15,7 @@ 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: IntegralFlowWithMultipliersCreateSpec::FIELDS, 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 5f3618a72..24d99b665 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -14,6 +14,7 @@ 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: KCliqueCreateSpec::FIELDS, diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9dfa3629f..d1fa16fde 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -18,6 +18,7 @@ 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: RuntimeKColoringCreateSpec::FIELDS, 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 d008f6b61..51f6204bc 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -17,6 +17,7 @@ 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: KthBestSpanningTreeCreateSpec::FIELDS, diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 4cde1aa97..7d4e7a366 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -18,6 +18,7 @@ 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: LengthBoundedDisjointPathsCreateSpec::FIELDS, diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 088070332..16d330f76 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -20,6 +20,7 @@ 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: LongestCircuitCreateSpec::FIELDS, diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index 74fd5f9a1..86e2292aa 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -20,6 +20,7 @@ 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: LongestPathI32CreateSpec::FIELDS, diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index cdba9c762..9ff562fc6 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -19,6 +19,7 @@ inventory::submit! { 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: MaxCutI32CreateSpec::FIELDS, diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index 9caaa80c3..1c750f1fb 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -19,6 +19,7 @@ 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: MaximalISCreateSpec::FIELDS, diff --git a/src/models/graph/maximum_achromatic_number.rs b/src/models/graph/maximum_achromatic_number.rs index 57d08f850..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: &[ diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 00eba9ba5..b7dd79e9c 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -19,6 +19,7 @@ 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: MaximumCliqueCreateSpec::::FIELDS, diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index a22e22a8c..969934cd7 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -26,6 +26,7 @@ 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: MaximumCoKPlexCreateSpec::::FIELDS, 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 518ebafff..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: &[ diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index e4d814e4d..74ab09b60 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -24,6 +24,7 @@ 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: MaximumEdgeWeightedKCliqueCreateSpec::::FIELDS, diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 1d3eb5888..f3e6d047b 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -19,6 +19,7 @@ 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: MaximumIndependentSetSimpleOneCreateSpec::FIELDS, diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 3feb0c489..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: &[ diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 73bb86524..f6137ca55 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -20,6 +20,7 @@ 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: MaximumMatchingCreateSpec::FIELDS, diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index a270a6cb9..52f002e28 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -19,6 +19,7 @@ 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: MinMaxMulticenterI32CreateSpec::FIELDS, diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 3de793ec8..2b008f232 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -22,6 +22,7 @@ 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: MinimumCapacitatedSpanningTreeCreateSpec::FIELDS, 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 05be374ba..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: &[ diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 855302fae..f13bf6991 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -20,6 +20,7 @@ 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: MinimumCutIntoBoundedSetsCreateSpec::FIELDS, diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index f3c5cad3f..932cefcbd 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -21,6 +21,7 @@ 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: MinimumDominatingSetCreateSpec::::FIELDS, @@ -246,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"]), diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs index 2c3b62251..10dc3a5e3 100644 --- a/src/models/graph/minimum_dummy_activities_pert.rs +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -20,6 +20,7 @@ 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: MinimumDummyActivitiesPertCreateSpec::FIELDS, 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 14049d9e1..cefc8dcf1 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -18,6 +18,7 @@ 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: MinimumFeedbackArcSetCreateSpec::FIELDS, diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index cb1130bf6..3b03e69cc 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -18,6 +18,7 @@ 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: MinimumFeedbackVertexSetCreateSpec::FIELDS, 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 19a894009..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: &[ diff --git a/src/models/graph/minimum_maximal_matching.rs b/src/models/graph/minimum_maximal_matching.rs index 3b4c53d5c..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: &[ 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 2a5009b4f..8143937b8 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -20,6 +20,7 @@ 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: MinimumMultiwayCutCreateSpec::FIELDS, diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index c2f9bba6d..fb98566b2 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -19,6 +19,7 @@ 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: MinimumSumMulticenterCreateSpec::FIELDS, diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 95ef994c8..82c5b2aa0 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -20,6 +20,7 @@ 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: MinimumVertexCoverCreateSpec::::FIELDS, @@ -251,6 +252,7 @@ crate::register_decision_variant!( "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"]), diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index 852372323..a0d067bf4 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -22,6 +22,7 @@ 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: MixedChinesePostmanI32CreateSpec::FIELDS, 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 f32796695..e334347ce 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -20,6 +20,7 @@ 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: MultipleChoiceBranchingCreateSpec::FIELDS, diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index a3b6d00db..433f7d144 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -16,6 +16,7 @@ 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: MultipleCopyFileAllocationCreateSpec::FIELDS, diff --git a/src/models/graph/optimal_linear_arrangement.rs b/src/models/graph/optimal_linear_arrangement.rs index 829fb046d..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: &[ @@ -193,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 ef988d001..f84035803 100644 --- a/src/models/graph/partial_feedback_edge_set.rs +++ b/src/models/graph/partial_feedback_edge_set.rs @@ -18,6 +18,7 @@ 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: PartialFeedbackEdgeSetCreateSpec::FIELDS, 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 8ad111ff2..8bc785b16 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -18,6 +18,7 @@ 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: PathConstrainedNetworkFlowCreateSpec::FIELDS, diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index e970a4aa8..412b1f051 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -42,6 +42,7 @@ 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: PrizeCollectingSteinerForestI32CreateSpec::FIELDS, diff --git a/src/models/graph/rooted_tree_arrangement.rs b/src/models/graph/rooted_tree_arrangement.rs index d5ac3e9f0..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: &[ diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index bb2f5c5b2..8743ffe05 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -20,6 +20,7 @@ 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: RuralPostmanCreateSpec::FIELDS, diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 4494edf6e..9518f1e25 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -21,6 +21,7 @@ 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: ShortestWeightConstrainedPathCreateSpec::FIELDS, diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index bdc8e4a62..9349e830a 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -17,6 +17,7 @@ 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: SpinGlassI32CreateSpec::FIELDS, diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 3b2a49bf1..5a805e042 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -24,6 +24,7 @@ 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: SteinerTreeCreateSpec::::FIELDS, diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs index e176e1d12..236ede264 100644 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -19,6 +19,7 @@ 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: SteinerTreeInGraphsCreateSpec::::FIELDS, 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 efbc98802..15a13700f 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -19,6 +19,7 @@ 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: TravelingSalesmanCreateSpec::FIELDS, diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index 38822478d..7d78832be 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -25,6 +25,7 @@ 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: UndirectedFlowLowerBoundsCreateSpec::FIELDS, diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index 9d8666821..d293f5643 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -14,6 +14,7 @@ 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: UndirectedTwoCommodityIntegralFlowCreateSpec::FIELDS, 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 7d6cc1332..1d34f66ae 100644 --- a/src/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/models/misc/boyce_codd_normal_form_violation.rs @@ -16,6 +16,7 @@ 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: BoyceCoddNormalFormViolationCreateSpec::FIELDS, diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index ecb7e27e5..4008bbe4d 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -13,6 +13,7 @@ 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: CapacityAssignmentCreateSpec::FIELDS, 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 fad6fdd9e..9179d1189 100644 --- a/src/models/misc/conjunctive_boolean_query.rs +++ b/src/models/misc/conjunctive_boolean_query.rs @@ -20,6 +20,7 @@ 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: ConjunctiveBooleanQueryCreateSpec::FIELDS, 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 250751da7..4f8dab42d 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -88,6 +88,7 @@ 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: ConsistencyOfDatabaseFrequencyTablesCreateSpec::FIELDS, 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 e9f99cb5b..eb2185edd 100644 --- a/src/models/misc/grouping_by_swapping.rs +++ b/src/models/misc/grouping_by_swapping.rs @@ -14,6 +14,7 @@ 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: GroupingBySwappingCreateSpec::FIELDS, 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 bdec8b68d..26733f47c 100644 --- a/src/models/misc/job_shop_scheduling.rs +++ b/src/models/misc/job_shop_scheduling.rs @@ -17,6 +17,7 @@ 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: JobShopSchedulingCreateSpec::FIELDS, diff --git a/src/models/misc/knapsack.rs b/src/models/misc/knapsack.rs index 1b268c486..d7c9e04e3 100644 --- a/src/models/misc/knapsack.rs +++ b/src/models/misc/knapsack.rs @@ -14,6 +14,7 @@ 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: KnapsackCreateSpec::FIELDS, diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 49489f939..ef5d378ce 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -16,6 +16,7 @@ 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: KthLargestMTupleCreateSpec::FIELDS, diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 20b05e6a3..351202096 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -16,6 +16,7 @@ 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: LongestCommonSubsequenceCreateSpec::FIELDS, 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 8fdbe13d3..c3948e944 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -15,6 +15,7 @@ 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: MinimumDecisionTreeCreateSpec::FIELDS, 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 a743fcc4c..94c7f7c16 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -19,6 +19,7 @@ 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: MinimumTardinessSequencingOneCreateSpec::FIELDS, diff --git a/src/models/misc/minimum_weight_and_or_graph.rs b/src/models/misc/minimum_weight_and_or_graph.rs index 662fc3595..d1a2d2f75 100644 --- a/src/models/misc/minimum_weight_and_or_graph.rs +++ b/src/models/misc/minimum_weight_and_or_graph.rs @@ -14,6 +14,7 @@ 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: MinimumWeightAndOrGraphCreateSpec::FIELDS, diff --git a/src/models/misc/multiprocessor_scheduling.rs b/src/models/misc/multiprocessor_scheduling.rs index 65d9ff2ca..7617024ef 100644 --- a/src/models/misc/multiprocessor_scheduling.rs +++ b/src/models/misc/multiprocessor_scheduling.rs @@ -14,6 +14,7 @@ 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: MultiprocessorSchedulingCreateSpec::FIELDS, 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 9cbb33bcc..f5ff161e7 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -17,6 +17,7 @@ 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: OpenShopSchedulingCreateSpec::FIELDS, diff --git a/src/models/misc/optimum_communication_spanning_tree.rs b/src/models/misc/optimum_communication_spanning_tree.rs index 41c0f7631..c354d8acf 100644 --- a/src/models/misc/optimum_communication_spanning_tree.rs +++ b/src/models/misc/optimum_communication_spanning_tree.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Optimum Communication Spanning Tree", aliases: &["OCST"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find spanning tree minimizing total weighted communication cost", fields: OptimumCommunicationSpanningTreeCreateSpec::FIELDS, 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 e70e3be46..57e9b8fcf 100644 --- a/src/models/misc/partially_ordered_knapsack.rs +++ b/src/models/misc/partially_ordered_knapsack.rs @@ -15,6 +15,7 @@ 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: PartiallyOrderedKnapsackCreateSpec::FIELDS, 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 b46dcc5f5..15f726e6b 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -14,6 +14,7 @@ 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: PrecedenceConstrainedSchedulingCreateSpec::FIELDS, diff --git a/src/models/misc/preemptive_scheduling.rs b/src/models/misc/preemptive_scheduling.rs index fbe1d98e3..2533ef869 100644 --- a/src/models/misc/preemptive_scheduling.rs +++ b/src/models/misc/preemptive_scheduling.rs @@ -16,6 +16,7 @@ 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: PreemptiveSchedulingCreateSpec::FIELDS, diff --git a/src/models/misc/production_planning.rs b/src/models/misc/production_planning.rs index e670d35e5..375a3592b 100644 --- a/src/models/misc/production_planning.rs +++ b/src/models/misc/production_planning.rs @@ -16,6 +16,7 @@ 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: ProductionPlanningCreateSpec::FIELDS, 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 24ac4fcb0..46fbb2bfe 100644 --- a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -17,6 +17,7 @@ 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: SchedulingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, diff --git a/src/models/misc/scheduling_with_individual_deadlines.rs b/src/models/misc/scheduling_with_individual_deadlines.rs index f48c82a06..e98cb534e 100644 --- a/src/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/models/misc/scheduling_with_individual_deadlines.rs @@ -15,6 +15,7 @@ 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: SchedulingWithIndividualDeadlinesCreateSpec::FIELDS, 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 b34045d65..ada08db48 100644 --- a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -15,6 +15,7 @@ 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: SequencingCumulativeCostCreateSpec::FIELDS, 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 3bd89bba6..2b16cf9d4 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -15,6 +15,7 @@ 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: SequencingToMinimizeTardyTaskWeightCreateSpec::FIELDS, 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 4390ecbe6..d02e32dd9 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -21,6 +21,7 @@ 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: SequencingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 46d2c0aa7..c3c5edc3f 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -15,6 +15,7 @@ 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: SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS, 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 8534f5019..53d4d4462 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -14,6 +14,7 @@ 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: SequencingWithinIntervalsCreateSpec::FIELDS, diff --git a/src/models/misc/shortest_common_supersequence.rs b/src/models/misc/shortest_common_supersequence.rs index 03204134f..cc878de3c 100644 --- a/src/models/misc/shortest_common_supersequence.rs +++ b/src/models/misc/shortest_common_supersequence.rs @@ -23,6 +23,7 @@ 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: ShortestCommonSupersequenceCreateSpec::FIELDS, 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 453266759..bcc136adb 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -17,6 +17,7 @@ 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: StackerCraneCreateSpec::FIELDS, diff --git a/src/models/misc/staff_scheduling.rs b/src/models/misc/staff_scheduling.rs index 990063e77..eae9d161b 100644 --- a/src/models/misc/staff_scheduling.rs +++ b/src/models/misc/staff_scheduling.rs @@ -14,6 +14,7 @@ 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: StaffSchedulingCreateSpec::FIELDS, diff --git a/src/models/misc/string_to_string_correction.rs b/src/models/misc/string_to_string_correction.rs index f884cc522..0e9df2528 100644 --- a/src/models/misc/string_to_string_correction.rs +++ b/src/models/misc/string_to_string_correction.rs @@ -24,6 +24,7 @@ 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: StringToStringCorrectionCreateSpec::FIELDS, 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 47c3f26e9..9f903de08 100644 --- a/src/models/misc/three_partition.rs +++ b/src/models/misc/three_partition.rs @@ -15,6 +15,7 @@ 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: ThreePartitionCreateSpec::FIELDS, diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index 94f687ac5..627dc1db7 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -14,6 +14,7 @@ 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: TimetableDesignCreateSpec::FIELDS, diff --git a/src/models/set/comparative_containment.rs b/src/models/set/comparative_containment.rs index 94d510c94..07e06af21 100644 --- a/src/models/set/comparative_containment.rs +++ b/src/models/set/comparative_containment.rs @@ -23,6 +23,7 @@ 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: ComparativeContainmentI32CreateSpec::FIELDS, 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 a4aab2288..9cc04deff 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -15,6 +15,7 @@ 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: ExactCoverBy3SetsCreateSpec::FIELDS, 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 6dede9a31..2b5eb139e 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -16,6 +16,7 @@ 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: MaximumSetPackingCreateSpec::::FIELDS, 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 17fdb7b97..04fef79f8 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -14,6 +14,7 @@ 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: MinimumHittingSetCreateSpec::FIELDS, diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index fb28fd1a5..fb0aea942 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -16,6 +16,7 @@ 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: MinimumSetCoveringCreateSpec::FIELDS, diff --git a/src/models/set/prime_attribute_name.rs b/src/models/set/prime_attribute_name.rs index d956424da..ccd9c9ed7 100644 --- a/src/models/set/prime_attribute_name.rs +++ b/src/models/set/prime_attribute_name.rs @@ -13,6 +13,7 @@ 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: PrimeAttributeNameCreateSpec::FIELDS, 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 b8fc22da4..8620e2961 100644 --- a/src/models/set/set_basis.rs +++ b/src/models/set/set_basis.rs @@ -14,6 +14,7 @@ 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: SetBasisCreateSpec::FIELDS, 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/registry/mod.rs b/src/registry/mod.rs index d1536462c..c5a220a27 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -56,8 +56,9 @@ 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_create_inputs, diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 85ada4acd..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; @@ -19,8 +19,8 @@ pub struct ProblemType { pub description: &'static str, /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], - /// Top-level model category derived from the declaring module path. - pub category: Option<&'static str>, + /// Explicit structural model category. + pub category: ProblemCategory, } impl ProblemType { @@ -33,7 +33,7 @@ impl ProblemType { dimensions: entry.dimensions, description: entry.description, fields: entry.fields, - category: problem_category_from_module_path(entry.module_path), + category: entry.category, } } @@ -46,12 +46,6 @@ impl ProblemType { } } -/// Extract a model category from `...::models::::...`. -pub(crate) fn problem_category_from_module_path(module_path: &str) -> Option<&str> { - let (_, model_path) = module_path.split_once("::models::")?; - model_path.split("::").next() -} - /// Find a problem type by exact canonical name. pub fn find_problem_type(name: &str) -> Option { inventory::iter:: diff --git a/src/registry/schema.rs b/src/registry/schema.rs index 0cf5ce15d..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,6 +125,8 @@ 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. @@ -72,6 +157,8 @@ pub struct ProblemSchemaJson { pub name: String, /// Problem description. pub description: String, + /// 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/rules/graph.rs b/src/rules/graph.rs index 697a72c83..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). @@ -1672,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 @@ -1684,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(), }, ) @@ -1845,15 +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 { - crate::registry::problem_type::problem_category_from_module_path(module_path) - .unwrap_or("other") - .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/registry/problem_type.rs b/src/unit_tests/registry/problem_type.rs index 6ca8cfdb3..02f87ce67 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(); diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 8950d9332..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,6 +84,7 @@ 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] diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index e1d2ed2bd..36461361a 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -8,7 +8,7 @@ use crate::models::graph::MaxCut; use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; -use crate::registry::problem_type::problem_category_from_module_path; +use crate::registry::ProblemCategory; use crate::rules::graph::{ReductionMode, ReductionStep}; use crate::rules::registry::{ReductionEntry, ReductionSizeDeclarations}; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; @@ -1037,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); @@ -1076,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!( @@ -1321,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] @@ -1399,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()); } } @@ -1508,29 +1478,6 @@ fn test_edges_have_doc_paths() { } } -#[test] -fn test_problem_category_from_module_path() { - assert_eq!( - problem_category_from_module_path( - "problemreductions::models::graph::maximum_independent_set" - ), - Some("graph") - ); - assert_eq!( - problem_category_from_module_path("problemreductions::models::formula::satisfiability"), - Some("formula") - ); - assert_eq!( - problem_category_from_module_path("problemreductions::models::set::maximum_set_packing"), - Some("set") - ); - assert_eq!( - problem_category_from_module_path("problemreductions::models::algebraic::qubo"), - Some("algebraic") - ); - assert_eq!(problem_category_from_module_path("unknown::path"), None); -} - #[test] fn test_reduce_along_path_direct() { let graph = ReductionGraph::new(); From ffa3dae7a98a3703bf19e89f335e26573eed591f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 12 Aug 2026 18:18:01 +0800 Subject: [PATCH 6/7] refactor: parse only selected create model --- .claude/CLAUDE.md | 2 +- problemreductions-cli/src/cli.rs | 80 +++++- .../src/commands/create/tests.rs | 73 +++--- problemreductions-cli/src/create_args.rs | 237 +++++++----------- problemreductions-cli/src/main.rs | 8 +- problemreductions-cli/tests/cli_tests.rs | 4 +- 6 files changed, 211 insertions(+), 193 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 97890640c..f3f3c7dbc 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -205,7 +205,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI - **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 deferred:** `pred create` expands flags only for the selected concrete variant. Ordinary models use `ProblemSchemaEntry.fields` directly. Models whose construction differs from persisted JSON own a typed `CreateSpec` and fallible conversion beside the model; CLI and MCP only normalize transport values and invoke the registered constructor. +- **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}`. diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 4a8138af1..70318700c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,5 +1,6 @@ -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +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; @@ -48,6 +49,35 @@ 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 { /// Browse registered problem types (or reduction rules with --rules) @@ -365,10 +395,10 @@ pub fn print_subcommand_help_hint(error_msg: &str) { #[cfg(test)] mod tests { use super::*; - use clap::{error::ErrorKind, Parser}; + use clap::error::ErrorKind; #[test] - fn dynamic_create_parser_uses_bounded_stack() { + fn two_stage_create_parser_uses_bounded_stack() { std::thread::Builder::new() .stack_size(1024 * 1024) .spawn(|| { @@ -449,9 +479,7 @@ mod tests { "0>1", ]) .expect("ordinary edges fields keep their schema-derived name"); - crate::create_args::with_static_completion_schema(|| { - Cli::command().debug_assert(); - }); + Cli::command().debug_assert(); }) .expect("spawn parser thread") .join() @@ -459,7 +487,7 @@ mod tests { } #[test] - fn dynamic_create_parser_preserves_problem_and_variant_aliases() { + fn two_stage_create_parser_preserves_problem_and_variant_aliases() { let cli = Cli::try_parse_from([ "pred", "create", @@ -478,7 +506,41 @@ mod tests { } #[test] - fn dynamic_create_parser_builds_every_registered_subcommand() { - Cli::command().debug_assert(); + fn selected_create_parser_preserves_trailing_global_arguments() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "MIS", + "--graph", + "0-1", + "--output", + "problem.json", + "--json", + ]) + .expect("global arguments parse after the selected model"); + assert_eq!(cli.output, Some(PathBuf::from("problem.json"))); + assert!(cli.json); + } + + #[test] + fn selected_create_parser_renders_model_help() { + let error = match Cli::try_parse_from(["pred", "create", "MIS", "--help"]) { + Ok(_) => panic!("help should exit after rendering the selected model command"), + Err(error) => error, + }; + assert_eq!(error.kind(), ErrorKind::DisplayHelp); + let help = error.to_string(); + assert!(help.contains("--graph")); + assert!(!help.contains("--clauses")); + } + + #[test] + fn static_create_parser_has_no_registered_model_subcommands() { + let command = Cli::command(); + let create = command + .find_subcommand("create") + .expect("create subcommand"); + assert_eq!(create.get_subcommands().count(), 0); + assert!(create.is_allow_external_subcommands_set()); } } diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 828cc4994..fde9a460d 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -2,8 +2,6 @@ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use clap::Parser; - use super::ensure_attribute_indices_in_range; use super::parse_bool_rows; use super::schema_support::*; @@ -64,7 +62,7 @@ fn test_parse_field_value_parses_job_shop_jobs() { #[test] fn test_create_schema_driven_builds_job_shop_scheduling() { - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", "JobShopScheduling", @@ -72,7 +70,8 @@ fn test_create_schema_driven_builds_job_shop_scheduling() { "0:3,1:4;1:2,0:3,1:2", "--num-processors", "2", - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); @@ -90,7 +89,8 @@ fn test_create_schema_driven_builds_job_shop_scheduling() { #[test] fn construction_contract_scs_uses_registered_spec_and_canonical_serialization() { - let cli = Cli::parse_from(["pred", "create", "SCS", "--strings", "0,1;1,2"]); + let cli = Cli::try_parse_from(["pred", "create", "SCS", "--strings", "0,1;1,2"]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -107,13 +107,14 @@ fn construction_contract_scs_uses_registered_spec_and_canonical_serialization() #[test] fn construction_contract_cli_discovers_test_only_registered_model() { - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", crate::test_support::AGGREGATE_SOURCE_NAME, "--values", "2,5,7", - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -132,7 +133,7 @@ fn construction_contract_cli_discovers_test_only_registered_model() { #[test] fn construction_contract_preserves_variant_declared_numeric_types() { let max_u64 = u64::MAX.to_string(); - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", "ThreePartition", @@ -140,7 +141,8 @@ fn construction_contract_preserves_variant_declared_numeric_types() { "6148914691236517205,6148914691236517205,6148914691236517205", "--bound", max_u64.as_str(), - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -148,7 +150,7 @@ fn construction_contract_preserves_variant_declared_numeric_types() { assert_eq!(data["bound"], serde_json::json!(u64::MAX)); let huge = "340282366920938463463374607431768211457"; - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", "SubsetSum", @@ -156,7 +158,8 @@ fn construction_contract_preserves_variant_declared_numeric_types() { huge, "--target", huge, - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -166,7 +169,7 @@ fn construction_contract_preserves_variant_declared_numeric_types() { #[test] fn construction_contract_biclique_uses_registered_composite_inputs() { - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", "BicliqueCover", @@ -178,7 +181,8 @@ fn construction_contract_biclique_uses_registered_composite_inputs() { "0-0,0-1,1-2", "--k", "2", - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -198,7 +202,7 @@ fn construction_contract_biclique_uses_registered_composite_inputs() { #[test] fn construction_contract_biclique_missing_input_comes_from_core_contract() { - let cli = Cli::parse_from([ + let cli = Cli::try_parse_from([ "pred", "create", "BicliqueCover", @@ -208,7 +212,8 @@ fn construction_contract_biclique_missing_input_comes_from_core_contract() { "3", "--k", "2", - ]); + ]) + .expect("create command parses"); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; @@ -224,7 +229,7 @@ Usage: pred create BicliqueCover --left --right --biedges = const { Cell::new(false) }; -} - -pub(crate) fn with_static_completion_schema(build: impl FnOnce() -> T) -> T { - STATIC_COMPLETION_BUILD.set(true); - let result = build(); - STATIC_COMPLETION_BUILD.set(false); - result -} - #[derive(Debug, Clone)] pub struct CreateArgs { pub problem: Option, @@ -77,6 +65,8 @@ impl Args for CreateArgs { fn augment_args(command: Command) -> Command { command .subcommand_required(false) + .allow_external_subcommands(true) + .subcommand_value_name("PROBLEM_SPEC") .arg(Arg::new(EXAMPLE).long(EXAMPLE).value_name("PROBLEM_SPEC")) .arg( Arg::new(EXAMPLE_TARGET) @@ -90,7 +80,6 @@ impl Args for CreateArgs { .value_parser(clap::builder::EnumValueParser::::new()) .default_value("source"), ) - .defer(add_problem_subcommands) } fn augment_args_for_update(command: Command) -> Command { @@ -147,132 +136,6 @@ fn os_value(value: &OsStr) -> String { .to_string() } -fn add_problem_subcommands(mut command: Command) -> Command { - let include_problem_flags = !STATIC_COMPLETION_BUILD.get(); - let problems = problem_types(); - let entries = variant_entries(); - let canonical_names = problems - .iter() - .map(|problem| problem.canonical_name) - .collect::>(); - for problem in problems { - for variant in variants_for(&problem, &entries) { - let names = names_for_variant(&problem, &variant, &entries, &canonical_names); - let Some((name, aliases)) = names.split_first() else { - continue; - }; - let canonical = problem.canonical_name.to_string(); - let variant_spec = name.clone(); - let mut subcommand = Command::new(name.clone()) - .about(problem.description) - .aliases(aliases.iter().cloned()) - .disable_help_subcommand(true); - if include_problem_flags { - subcommand = subcommand.defer(add_selected_problem_args); - } - command = command.subcommand( - subcommand.long_about(format!("Create a {canonical} instance ({variant_spec})")), - ); - } - } - command -} - -fn variants_for( - problem: &ProblemType, - entries: &[&problemreductions::registry::VariantEntry], -) -> Vec> { - let mut variants = entries - .iter() - .filter(|entry| entry.name == problem.canonical_name) - .map(|entry| entry.variant_map()) - .collect::>(); - variants.sort(); - variants.dedup(); - variants -} - -fn names_for_variant( - problem: &ProblemType, - variant: &BTreeMap, - entries: &[&problemreductions::registry::VariantEntry], - canonical_names: &BTreeSet<&str>, -) -> Vec { - let mut prefixes = vec![problem.canonical_name]; - prefixes.extend( - problem - .aliases - .iter() - .copied() - .filter(|alias| !is_other_problem_name(problem.canonical_name, alias, canonical_names)), - ); - - let non_default = problem - .dimensions - .iter() - .filter(|dimension| { - dimension_value(variant, dimension.key, dimension.default_value) - != dimension.default_value - }) - .collect::>(); - let mut names = BTreeSet::new(); - for prefix in prefixes { - let suffix = non_default - .iter() - .map(|dimension| dimension_value(variant, dimension.key, dimension.default_value)) - .collect::>(); - names.insert(join_spec(prefix, &suffix)); - - let full = problem - .dimensions - .iter() - .map(|dimension| dimension_value(variant, dimension.key, dimension.default_value)) - .collect::>(); - names.insert(join_spec(prefix, &full)); - } - - for entry in entries - .iter() - .filter(|entry| entry.name == problem.canonical_name && entry.variant_map() == *variant) - { - names.extend( - entry - .aliases - .iter() - .filter(|alias| { - !is_other_problem_name(problem.canonical_name, alias, canonical_names) - }) - .map(|alias| (*alias).to_string()), - ); - } - - let canonical = join_spec( - problem.canonical_name, - &non_default - .iter() - .map(|dimension| dimension_value(variant, dimension.key, dimension.default_value)) - .collect::>(), - ); - let mut names = names.into_iter().collect::>(); - names.sort(); - let position = names - .iter() - .position(|name| name == &canonical) - .expect("canonical create command name"); - names.swap(0, position); - names -} - -fn is_other_problem_name( - canonical: &str, - candidate: &str, - canonical_names: &BTreeSet<&str>, -) -> bool { - canonical_names - .iter() - .any(|name| *name != canonical && name.eq_ignore_ascii_case(candidate)) -} - fn dimension_value<'a>( variant: &'a BTreeMap, key: &str, @@ -289,10 +152,12 @@ fn join_spec(prefix: &str, values: &[&str]) -> String { } } -fn add_selected_problem_args(mut command: Command) -> Command { - let selected = command.get_name().to_string(); - let (canonical, variant) = resolve_registered_create_variant(&selected); - let inputs = crate::commands::create::create_inputs_for(canonical, &variant); +fn add_selected_problem_args( + mut command: Command, + canonical: &str, + variant: &BTreeMap, +) -> Command { + let inputs = crate::commands::create::create_inputs_for(canonical, variant); for input in inputs { let mut arg = Arg::new(input.name.clone()).long(input.name.clone()); @@ -310,6 +175,88 @@ fn add_selected_problem_args(mut command: Command) -> Command { command } +pub(crate) fn command_for_selected_problem( + mut command: Command, + selected: &str, +) -> Result { + let mut parts = selected.split('/'); + let selected_name = parts.next().expect("selected problem spec has a name"); + let catalog_problem = problemreductions::registry::find_problem_type_by_alias(selected_name); + let spec = if let Some(problem) = catalog_problem { + crate::problem_name::ProblemSpec { + name: problem.canonical_name.to_string(), + variant_values: parts.map(str::to_string).collect(), + } + } else { + crate::problem_name::parse_problem_spec(selected) + .map_err(|error| invalid_problem_spec(&command, error.to_string()))? + }; + let problem = problemreductions::registry::find_problem_type(&spec.name).ok_or_else(|| { + invalid_problem_spec( + &command, + crate::problem_name::unknown_problem_error(&spec.name), + ) + })?; + let problem_ref = + problemreductions::registry::ProblemRef::from_values(&problem, &spec.variant_values) + .map_err(|error| invalid_problem_spec(&command, error))?; + if problemreductions::registry::find_variant_entry(problem_ref.name(), problem_ref.variant()) + .is_none() + { + return Err(invalid_problem_spec( + &command, + format!( + "No concrete variant is registered for {} with {:?}", + problem_ref.name(), + problem_ref.variant() + ), + )); + } + + let canonical_spec = canonical_problem_spec(&problem, problem_ref.variant()); + let mut selected_command = Command::new(canonical_spec.clone()) + .about(problem.description) + .long_about(format!( + "Create a {} instance ({canonical_spec})", + problem.canonical_name + )) + .disable_help_subcommand(true); + if selected != canonical_spec { + selected_command = selected_command.alias(selected.to_string()); + } + selected_command = add_selected_problem_args( + selected_command, + problem.canonical_name, + problem_ref.variant(), + ); + + let create = command + .find_subcommand_mut("create") + .expect("Cli has a create subcommand"); + *create = std::mem::take(create) + .allow_external_subcommands(false) + .subcommand(selected_command); + Ok(command) +} + +fn invalid_problem_spec(command: &Command, message: String) -> Error { + command + .clone() + .error(clap::error::ErrorKind::InvalidSubcommand, message) +} + +fn canonical_problem_spec(problem: &ProblemType, variant: &BTreeMap) -> String { + let values = problem + .dimensions + .iter() + .filter_map(|dimension| { + let value = dimension_value(variant, dimension.key, dimension.default_value); + (value != dimension.default_value).then_some(value) + }) + .collect::>(); + join_spec(problem.canonical_name, &values) +} + pub(crate) fn resolve_registered_create_variant( selected: &str, ) -> (&'static str, BTreeMap) { diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index aa4361ae5..c725d659e 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -10,7 +10,7 @@ mod problem_name; mod test_support; mod util; -use clap::{CommandFactory, Parser}; +use clap::CommandFactory; use cli::{Cli, Commands}; use output::OutputConfig; @@ -87,10 +87,8 @@ fn main() -> anyhow::Result<()> { let shell = shell .or_else(clap_complete::Shell::from_env) .unwrap_or(clap_complete::Shell::Bash); - create_args::with_static_completion_schema(|| { - let mut cmd = Cli::command(); - clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout()); - }); + let mut cmd = Cli::command(); + clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout()); Ok(()) } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 44f8a31d8..b563efe11 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -7788,8 +7788,8 @@ fn test_create_mvc_kings_subgraph_unsupported_variant() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains("unrecognized subcommand 'MVC/KingsSubgraph'"), - "should reject the unregistered variant command: {stderr}" + stderr.contains("Unknown variant value \"KingsSubgraph\""), + "should reject the unregistered variant: {stderr}" ); } From 43c89d05925751a551e0860c910aee7387c8b09e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 12 Aug 2026 18:49:01 +0800 Subject: [PATCH 7/7] fix: remove conflicting graph partitioning alias --- problemreductions-cli/src/cli.rs | 11 +++++++++++ problemreductions-cli/src/create_args.rs | 14 ++------------ problemreductions-cli/src/problem_name.rs | 3 --- src/models/graph/max_cut.rs | 2 +- src/unit_tests/registry/problem_type.rs | 10 ++++++++++ 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 70318700c..78c5c024e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -505,6 +505,17 @@ mod tests { assert_eq!(args.raw("num-vars"), Some("3")); } + #[test] + fn canonical_problem_name_is_not_treated_as_an_alias() { + let cli = + Cli::try_parse_from(["pred", "create", "GraphPartitioning", "--graph", "0-1,2-3"]) + .expect("canonical problem name parses"); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + assert_eq!(args.problem.as_deref(), Some("GraphPartitioning")); + } + #[test] fn selected_create_parser_preserves_trailing_global_arguments() { let cli = Cli::try_parse_from([ diff --git a/problemreductions-cli/src/create_args.rs b/problemreductions-cli/src/create_args.rs index 9fff6be91..ef4cd735a 100644 --- a/problemreductions-cli/src/create_args.rs +++ b/problemreductions-cli/src/create_args.rs @@ -179,18 +179,8 @@ pub(crate) fn command_for_selected_problem( mut command: Command, selected: &str, ) -> Result { - let mut parts = selected.split('/'); - let selected_name = parts.next().expect("selected problem spec has a name"); - let catalog_problem = problemreductions::registry::find_problem_type_by_alias(selected_name); - let spec = if let Some(problem) = catalog_problem { - crate::problem_name::ProblemSpec { - name: problem.canonical_name.to_string(), - variant_values: parts.map(str::to_string).collect(), - } - } else { - crate::problem_name::parse_problem_spec(selected) - .map_err(|error| invalid_problem_spec(&command, error.to_string()))? - }; + let spec = crate::problem_name::parse_problem_spec(selected) + .map_err(|error| invalid_problem_spec(&command, error.to_string()))?; let problem = problemreductions::registry::find_problem_type(&spec.name).ok_or_else(|| { invalid_problem_spec( &command, diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index a08c72e41..ec22fe904 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -42,9 +42,6 @@ pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("ThreeMatroidIntersection") { return "ThreeMatroidIntersection".to_string(); } - if input.eq_ignore_ascii_case("GraphPartitioning") { - return "GraphPartitioning".to_string(); - } if let Some((entry, _)) = problemreductions::registry::find_variant_by_alias(input) { return entry.name.to_string(); } diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 9ff562fc6..6df88f83a 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -14,7 +14,7 @@ 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"]), diff --git a/src/unit_tests/registry/problem_type.rs b/src/unit_tests/registry/problem_type.rs index 02f87ce67..aa5aac47e 100644 --- a/src/unit_tests/registry/problem_type.rs +++ b/src/unit_tests/registry/problem_type.rs @@ -201,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 {}",